From f770d0fad6eefd5baa927f6d6d87401d1835e301 Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Thu, 29 Jun 2023 10:47:45 -0500
Subject: [PATCH 01/75] Begin TextView implementation
---
...ABF49830-0162-46D3-AAD0-396278570495.plist | 22 +
.../Info.plist | 33 ++
.../CodeEditTextView/CodeEditTextView.swift | 130 +++---
.../TextView/CETiledLayer.swift | 50 ++
.../TextView/MultiStorageDelegate.swift | 39 ++
.../TextLayoutLineStorage.swift | 435 ++++++++++++++++++
.../TextLayoutLineStorageNode.swift | 120 +++++
.../TextLayoutManager/TextLayoutManager.swift | 59 +++
.../CodeEditTextView/TextView/TextLine.swift | 41 ++
.../CodeEditTextView/TextView/TextView.swift | 92 ++++
.../TextView/TextViewController.swift | 43 ++
.../TextView/Typesetter.swift | 60 +++
.../TextLayoutLineStorageTests.swift | 63 +++
13 files changed, 1123 insertions(+), 64 deletions(-)
create mode 100644 .swiftpm/xcode/xcshareddata/xcbaselines/CodeEditTextViewTests.xcbaseline/ABF49830-0162-46D3-AAD0-396278570495.plist
create mode 100644 .swiftpm/xcode/xcshareddata/xcbaselines/CodeEditTextViewTests.xcbaseline/Info.plist
create mode 100644 Sources/CodeEditTextView/TextView/CETiledLayer.swift
create mode 100644 Sources/CodeEditTextView/TextView/MultiStorageDelegate.swift
create mode 100644 Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutLineStorage/TextLayoutLineStorage.swift
create mode 100644 Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutLineStorage/TextLayoutLineStorageNode.swift
create mode 100644 Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
create mode 100644 Sources/CodeEditTextView/TextView/TextLine.swift
create mode 100644 Sources/CodeEditTextView/TextView/TextView.swift
create mode 100644 Sources/CodeEditTextView/TextView/TextViewController.swift
create mode 100644 Sources/CodeEditTextView/TextView/Typesetter.swift
create mode 100644 Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
diff --git a/.swiftpm/xcode/xcshareddata/xcbaselines/CodeEditTextViewTests.xcbaseline/ABF49830-0162-46D3-AAD0-396278570495.plist b/.swiftpm/xcode/xcshareddata/xcbaselines/CodeEditTextViewTests.xcbaseline/ABF49830-0162-46D3-AAD0-396278570495.plist
new file mode 100644
index 000000000..9080d8c2b
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcbaselines/CodeEditTextViewTests.xcbaseline/ABF49830-0162-46D3-AAD0-396278570495.plist
@@ -0,0 +1,22 @@
+
+
+
+
+ classNames
+
+ TextLayoutLineStorageTests
+
+ test_insertPerformance()
+
+ com.apple.XCTPerformanceMetric_WallClockTime
+
+ baselineAverage
+ 2.030000
+ baselineIntegrationDisplayName
+ Local Baseline
+
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcbaselines/CodeEditTextViewTests.xcbaseline/Info.plist b/.swiftpm/xcode/xcshareddata/xcbaselines/CodeEditTextViewTests.xcbaseline/Info.plist
new file mode 100644
index 000000000..f03b670cc
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcbaselines/CodeEditTextViewTests.xcbaseline/Info.plist
@@ -0,0 +1,33 @@
+
+
+
+
+ runDestinationsByUUID
+
+ ABF49830-0162-46D3-AAD0-396278570495
+
+ localComputer
+
+ busSpeedInMHz
+ 0
+ cpuCount
+ 1
+ cpuKind
+ Apple M1 Pro
+ cpuSpeedInMHz
+ 0
+ logicalCPUCoresPerPackage
+ 10
+ modelCode
+ MacBookPro18,3
+ physicalCPUCoresPerPackage
+ 10
+ platformIdentifier
+ com.apple.platform.macosx
+
+ targetArchitecture
+ arm64
+
+
+
+
diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift
index a0ac4390e..079f187a9 100644
--- a/Sources/CodeEditTextView/CodeEditTextView.swift
+++ b/Sources/CodeEditTextView/CodeEditTextView.swift
@@ -88,78 +88,80 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
private var letterSpacing: Double
private var bracketPairHighlight: BracketPairHighlight?
- public typealias NSViewControllerType = STTextViewController
+ public typealias NSViewControllerType = TextViewController // STTextViewController
- public func makeNSViewController(context: Context) -> NSViewControllerType {
- let controller = NSViewControllerType(
- text: $text,
- language: language,
- font: font,
- theme: theme,
- tabWidth: tabWidth,
- indentOption: indentOption,
- lineHeight: lineHeight,
- wrapLines: wrapLines,
- cursorPosition: $cursorPosition,
- editorOverscroll: editorOverscroll,
- useThemeBackground: useThemeBackground,
- highlightProvider: highlightProvider,
- contentInsets: contentInsets,
- isEditable: isEditable,
- letterSpacing: letterSpacing,
- bracketPairHighlight: bracketPairHighlight
- )
- return controller
+ public func makeNSViewController(context: Context) -> TextViewController {
+// let controller = NSViewControllerType(
+// text: $text,
+// language: language,
+// font: font,
+// theme: theme,
+// tabWidth: tabWidth,
+// indentOption: indentOption,
+// lineHeight: lineHeight,
+// wrapLines: wrapLines,
+// cursorPosition: $cursorPosition,
+// editorOverscroll: editorOverscroll,
+// useThemeBackground: useThemeBackground,
+// highlightProvider: highlightProvider,
+// contentInsets: contentInsets,
+// isEditable: isEditable,
+// letterSpacing: letterSpacing,
+// bracketPairHighlight: bracketPairHighlight
+// )
+// return controller
+ return TextViewController(string: text)
}
- public func updateNSViewController(_ controller: NSViewControllerType, context: Context) {
+ public func updateNSViewController(_ controller: TextViewController, context: Context) {
// Do manual diffing to reduce the amount of reloads.
// This helps a lot in view performance, as it otherwise gets triggered on each environment change.
- guard !paramsAreEqual(controller: controller) else {
- return
- }
-
- controller.font = font
- controller.wrapLines = wrapLines
- controller.useThemeBackground = useThemeBackground
- controller.lineHeightMultiple = lineHeight
- controller.editorOverscroll = editorOverscroll
- controller.contentInsets = contentInsets
- controller.bracketPairHighlight = bracketPairHighlight
-
- // Updating the language, theme, tab width and indent option needlessly can cause highlights to be re-calculated
- if controller.language.id != language.id {
- controller.language = language
- }
- if controller.theme != theme {
- controller.theme = theme
- }
- if controller.indentOption != indentOption {
- controller.indentOption = indentOption
- }
- if controller.tabWidth != tabWidth {
- controller.tabWidth = tabWidth
- }
- if controller.letterSpacing != letterSpacing {
- controller.letterSpacing = letterSpacing
- }
-
- controller.reloadUI()
+// guard !paramsAreEqual(controller: controller) else {
+// return
+// }
+//
+// controller.font = font
+// controller.wrapLines = wrapLines
+// controller.useThemeBackground = useThemeBackground
+// controller.lineHeightMultiple = lineHeight
+// controller.editorOverscroll = editorOverscroll
+// controller.contentInsets = contentInsets
+// controller.bracketPairHighlight = bracketPairHighlight
+//
+// // Updating the language, theme, tab width and indent option needlessly can cause highlights to be re-calculated
+// if controller.language.id != language.id {
+// controller.language = language
+// }
+// if controller.theme != theme {
+// controller.theme = theme
+// }
+// if controller.indentOption != indentOption {
+// controller.indentOption = indentOption
+// }
+// if controller.tabWidth != tabWidth {
+// controller.tabWidth = tabWidth
+// }
+// if controller.letterSpacing != letterSpacing {
+// controller.letterSpacing = letterSpacing
+// }
+//
+// controller.reloadUI()
return
}
func paramsAreEqual(controller: NSViewControllerType) -> Bool {
- controller.font == font &&
- controller.wrapLines == wrapLines &&
- controller.useThemeBackground == useThemeBackground &&
- controller.lineHeightMultiple == lineHeight &&
- controller.editorOverscroll == editorOverscroll &&
- controller.contentInsets == contentInsets &&
- controller.language.id == language.id &&
- controller.theme == theme &&
- controller.indentOption == indentOption &&
- controller.tabWidth == tabWidth &&
- controller.letterSpacing == letterSpacing &&
- controller.bracketPairHighlight == bracketPairHighlight
+ true
+// controller.font == font &&
+// controller.wrapLines == wrapLines &&
+// controller.useThemeBackground == useThemeBackground &&
+// controller.lineHeightMultiple == lineHeight &&
+// controller.editorOverscroll == editorOverscroll &&
+// controller.contentInsets == contentInsets &&
+// controller.language.id == language.id &&
+// controller.theme == theme &&
+// controller.indentOption == indentOption &&
+// controller.tabWidth == tabWidth &&
+// controller.letterSpacing == letterSpacing &&
+// controller.bracketPairHighlight == bracketPairHighlight
}
}
diff --git a/Sources/CodeEditTextView/TextView/CETiledLayer.swift b/Sources/CodeEditTextView/TextView/CETiledLayer.swift
new file mode 100644
index 000000000..760d305f7
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/CETiledLayer.swift
@@ -0,0 +1,50 @@
+//
+// CETiledLayer.swift
+//
+//
+// Created by Khan Winter on 6/27/23.
+//
+
+import Cocoa
+
+class CETiledLayer: CATiledLayer {
+ open override class func fadeDuration() -> CFTimeInterval {
+ 0
+ }
+
+ override public class func defaultAction(forKey event: String) -> CAAction? {
+ return NSNull()
+ }
+
+ /// A dictionary containing layer actions.
+ /// Disable animations
+ override public var actions: [String : CAAction]? {
+ set {
+ return
+ }
+ get {
+ super.actions
+ }
+ }
+
+ public override init() {
+ super.init()
+ needsDisplayOnBoundsChange = true
+ }
+
+ public init(frame frameRect: CGRect) {
+ super.init()
+ needsDisplayOnBoundsChange = true
+ frame = frameRect
+ }
+
+ public required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ needsDisplayOnBoundsChange = true
+ }
+
+ public override init(layer: Any) {
+ super.init(layer: layer)
+ needsDisplayOnBoundsChange = true
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/MultiStorageDelegate.swift b/Sources/CodeEditTextView/TextView/MultiStorageDelegate.swift
new file mode 100644
index 000000000..ab737c650
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/MultiStorageDelegate.swift
@@ -0,0 +1,39 @@
+//
+// MultiStorageDelegate.swift
+//
+//
+// Created by Khan Winter on 6/25/23.
+//
+
+import AppKit
+
+class MultiStorageDelegate: NSObject, NSTextStorageDelegate {
+ private var delegates = NSHashTable.weakObjects()
+
+ func addDelegate(_ delegate: NSTextStorageDelegate) {
+ delegates.add(delegate)
+ }
+
+ func textStorage(
+ _ textStorage: NSTextStorage,
+ didProcessEditing editedMask: NSTextStorageEditActions,
+ range editedRange: NSRange,
+ changeInLength delta: Int
+ ) {
+ delegates.allObjects.forEach { delegate in
+ delegate.textStorage?(textStorage, didProcessEditing: editedMask, range: editedRange, changeInLength: delta)
+ }
+ }
+
+ func textStorage(
+ _ textStorage: NSTextStorage,
+ willProcessEditing editedMask: NSTextStorageEditActions,
+ range editedRange: NSRange,
+ changeInLength delta: Int
+ ) {
+ delegates.allObjects.forEach { delegate in
+ delegate
+ .textStorage?(textStorage, willProcessEditing: editedMask, range: editedRange, changeInLength: delta)
+ }
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutLineStorage/TextLayoutLineStorage.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutLineStorage/TextLayoutLineStorage.swift
new file mode 100644
index 000000000..ccb78831a
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutLineStorage/TextLayoutLineStorage.swift
@@ -0,0 +1,435 @@
+//
+// TextLayoutLineStorage.swift
+//
+//
+// Created by Khan Winter on 6/25/23.
+//
+
+import Foundation
+
+/// Implements a red-black tree for efficiently editing, storing and retrieving `TextLine`s.
+final class TextLayoutLineStorage {
+#if DEBUG
+ var root: Node?
+#else
+ private var root: Node?
+#endif
+ /// The number of characters in the storage object.
+ private(set) public var length: Int = 0
+ /// The number of lines in the storage object
+ private(set) public var count: Int = 0
+
+ init() { }
+
+ // MARK: - Public Methods
+
+ /// Inserts a new line for the given range.
+ /// - Parameters:
+ /// - line: The text line to insert
+ /// - range: The range the line represents. If the range is empty the line will be ignored.
+ public func insert(atIndex index: Int, length: Int) {
+ assert(index >= 0 && index <= self.length, "Invalid index, expected between 0 and \(self.length). Got \(index)")
+ defer {
+ self.length += length
+ }
+
+ let insertedNode = Node(length: length, leftSubtreeOffset: 0, color: .black)
+ guard root != nil else {
+ root = insertedNode
+ return
+ }
+ insertedNode.color = .red
+
+ var currentNode = root
+ var currentOffset: Int = root?.leftSubtreeOffset ?? 0
+ while let node = currentNode {
+ if currentOffset >= index {
+ if node.left != nil {
+ currentNode = node.left
+ currentOffset -= (node.left?.leftSubtreeOffset ?? 0) + (node.left?.length ?? 0)
+ } else {
+ node.left = insertedNode
+ insertedNode.parent = node
+ currentNode = nil
+ }
+ } else {
+ if node.right != nil {
+ currentNode = node.right
+ currentOffset += node.length + (node.right?.leftSubtreeOffset ?? 0)
+ } else {
+ node.right = insertedNode
+ insertedNode.parent = node
+ currentNode = nil
+ }
+ }
+ }
+
+ insertFixup(node: insertedNode)
+ }
+
+ /// Fetches a line for the given index.
+ ///
+ /// Complexity: `O(log n)`
+ /// - Parameter index: The index to fetch for.
+ /// - Returns: A text line object representing a generated line object and the offset in the document of the line.
+ /// If the line was not found, the offset will be `-1`.
+// public func getLine(atIndex index: Int) -> (TextLine?, Int) {
+// let result = search(for: index)
+// return (result.0?.line, result.1)
+// }
+
+ /// Applies a length change at the given index.
+ ///
+ /// If a character was deleted, delta should be negative.
+ /// The `index` parameter should represent where the edit began.
+ ///
+ /// Complexity: `O(m log n)` where `m` is the number of lines that need to be deleted as a result of this update.
+ /// and `n` is the number of lines stored in the tree.
+ ///
+ /// Lines will be deleted if the delta is both negative and encompases the entire line.
+ /// - Parameters:
+ /// - index: The indice where the edit began
+ /// - delta: The change in length of the document. Negative for deletes, positive for insertions.
+ public func update(atIndex index: Int, delta: Int) {
+ assert(index >= 0 && index < self.length, "Invalid index, expected between 0 and \(self.length). Got \(index)")
+ assert(delta != 0, "Delta must be non-0")
+ if delta < 0 {
+ var remainingDelta = -1 * delta
+ var index = index
+ while remainingDelta > 0 {
+ let (node, offset) = search(for: index)
+ guard let node, offset > -1 else { return }
+ let nodeDelta = index - offset
+ node.length -= min(remainingDelta, nodeDelta)
+ remainingDelta -= min(remainingDelta, nodeDelta)
+ index = offset - 1
+
+ metaFixup(startingAt: node, delta: -nodeDelta)
+ if node.length == 0 {
+
+ }
+ }
+ } else {
+ let (node, offset) = search(for: index)
+ guard let node, offset > -1 else { return }
+ node.length += delta
+ metaFixup(startingAt: node, delta: delta)
+ }
+ }
+
+ /// Deletes a line at the given index.
+ ///
+ /// Will return if a line could not be found for the given index, and throw an assertion error if the index is
+ /// out of bounds.
+ /// - Parameter index: The index to delete a line at.
+ public func delete(lineAt index: Int) {
+ assert(index >= 0 && index < self.length, "Invalid index, expected between 0 and \(self.length). Got \(index)")
+ guard let nodeZ = search(for: index).0 else { return }
+ var nodeX: Node
+ var nodeY: Node
+
+ }
+
+ public func printTree() {
+ print(
+ treeString(root!) { node in
+ (
+ "\(node.length)[\(node.leftSubtreeOffset)\(node.color == .red ? "R" : "B")]",
+ node.left,
+ node.right
+ )
+ }
+ )
+ }
+}
+
+private extension TextLayoutLineStorage {
+ // MARK: - Search
+
+ /// Searches for the given index. Returns a node and offset if found.
+ /// - Parameter index: The index to look for in the document.
+ /// - Returns: A tuple containing a node if it was found, and the offset of the node in the document.
+ /// The index will be negative if the node was not found.
+ func search(for index: Int) -> (Node?, Int) {
+ var currentNode = root
+ var currentOffset: Int = root?.leftSubtreeOffset ?? 0
+ while let node = currentNode {
+ // If index is in the range [currentOffset..= currentOffset && index < currentOffset + node.length {
+ return (node, currentOffset)
+ } else if currentOffset > index {
+ currentNode = node.left
+ currentOffset -= (node.left?.leftSubtreeOffset ?? 0) + (node.left?.length ?? 0)
+ } else if node.leftSubtreeOffset < index {
+ currentNode = node.right
+ currentOffset += node.length + (node.right?.leftSubtreeOffset ?? 0)
+ } else {
+ currentNode = nil
+ }
+ }
+
+ return (nil, -1)
+ }
+
+ // MARK: - Fixup
+
+ func insertFixup(node: Node) {
+ metaFixup(startingAt: node, delta: node.length)
+
+ var nextNode: Node? = node
+ while var nodeX = nextNode, nodeX != root, let nodeXParent = nodeX.parent, nodeXParent.color == .red {
+ let nodeY = sibling(nodeXParent)
+ if isLeftChild(nodeXParent) {
+ if nodeY?.color == .red {
+ nodeXParent.color = .black
+ nodeY?.color = .black
+ nodeX.parent?.parent?.color = .red
+ nextNode = nodeX.parent?.parent
+ } else {
+ if isRightChild(nodeX) {
+ nodeX = nodeXParent
+ leftRotate(node: nodeX)
+ }
+
+ nodeX.parent?.color = .black
+ nodeX.parent?.parent?.color = .red
+ if let grandparent = nodeX.parent?.parent {
+ rightRotate(node: grandparent)
+ }
+ }
+ } else {
+ if nodeY?.color == .red {
+ nodeXParent.color = .black
+ nodeY?.color = .black
+ nodeX.parent?.parent?.color = .red
+ nextNode = nodeX.parent?.parent
+ } else {
+ if isLeftChild(nodeX) {
+ nodeX = nodeXParent
+ rightRotate(node: nodeX)
+ }
+
+ nodeX.parent?.color = .black
+ nodeX.parent?.parent?.color = .red
+ if let grandparent = nodeX.parent?.parent {
+ leftRotate(node: grandparent)
+ }
+ }
+ }
+ }
+
+ root?.color = .black
+ }
+
+ /// RB Tree Deletes `:(`
+ func deleteFixup(node: Node) {
+
+ }
+
+ /// Walk up the tree, updating any `leftSubtree` metadata.
+ func metaFixup(startingAt node: Node, delta: Int) {
+ guard node.parent != nil, delta > 0 else { return }
+ // Find the first node that needs to be updated (first left child)
+ var nodeX: Node? = node
+ while nodeX != nil, isRightChild(nodeX!) && nodeX!.parent != nil {
+ nodeX = nodeX?.parent
+ }
+
+ guard var nodeX else { return }
+ var count = 0
+ while nodeX != root {
+ if isLeftChild(nodeX) {
+ nodeX.parent?.leftSubtreeOffset += delta
+ }
+ if let parent = nodeX.parent {
+ count += 1
+ nodeX = parent
+ } else {
+ print("Meta Fixup, len: \(length), num items touched: \(count)")
+ return
+ }
+ }
+ }
+
+ func calculateSize(_ node: Node?) -> Int {
+ guard let node else { return 0 }
+ return node.length + node.leftSubtreeOffset + calculateSize(node.right)
+ }
+}
+
+// MARK: - Rotations
+
+private extension TextLayoutLineStorage {
+ func rightRotate(node: Node) {
+ rotate(node: node, left: false)
+ }
+
+ func leftRotate(node: Node) {
+ rotate(node: node, left: true)
+ }
+
+ func rotate(node: Node, left: Bool) {
+ var nodeY: Node?
+
+ if left {
+ nodeY = node.right
+ nodeY?.leftSubtreeOffset += node.leftSubtreeOffset + node.length
+ node.right = nodeY?.left
+ node.right?.parent = node
+ } else {
+ nodeY = node.left
+ node.left = nodeY?.right
+ node.left?.parent = node
+ }
+
+ nodeY?.parent = node.parent
+ if node.parent == nil {
+ if let node = nodeY {
+ root = node
+ }
+ } else if isLeftChild(node) {
+ node.parent?.left = nodeY
+ } else if isRightChild(node) {
+ node.parent?.right = nodeY
+ }
+
+ if left {
+ nodeY?.left = node
+ } else {
+ nodeY?.right = node
+ node.leftSubtreeOffset = (node.left?.length ?? 0) + (node.left?.leftSubtreeOffset ?? 0)
+ }
+ node.parent = nodeY
+ }
+}
+
+// swiftlint:disable all
+// Awesome tree printing function from https://stackoverflow.com/a/43903427/10453550
+public func treeString(_ node:T, reversed:Bool=false, isTop:Bool=true, using nodeInfo:(T)->(String,T?,T?)) -> String {
+ // node value string and sub nodes
+ let (stringValue, leftNode, rightNode) = nodeInfo(node)
+
+ let stringValueWidth = stringValue.count
+
+ // recurse to sub nodes to obtain line blocks on left and right
+ let leftTextBlock = leftNode == nil ? []
+ : treeString(leftNode!,reversed:reversed,isTop:false,using:nodeInfo)
+ .components(separatedBy:"\n")
+
+ let rightTextBlock = rightNode == nil ? []
+ : treeString(rightNode!,reversed:reversed,isTop:false,using:nodeInfo)
+ .components(separatedBy:"\n")
+
+ // count common and maximum number of sub node lines
+ let commonLines = min(leftTextBlock.count,rightTextBlock.count)
+ let subLevelLines = max(rightTextBlock.count,leftTextBlock.count)
+
+ // extend lines on shallower side to get same number of lines on both sides
+ let leftSubLines = leftTextBlock
+ + Array(repeating:"", count: subLevelLines-leftTextBlock.count)
+ let rightSubLines = rightTextBlock
+ + Array(repeating:"", count: subLevelLines-rightTextBlock.count)
+
+ // compute location of value or link bar for all left and right sub nodes
+ // * left node's value ends at line's width
+ // * right node's value starts after initial spaces
+ let leftLineWidths = leftSubLines.map{$0.count}
+ let rightLineIndents = rightSubLines.map{$0.prefix{$0==" "}.count }
+
+ // top line value locations, will be used to determine position of current node & link bars
+ let firstLeftWidth = leftLineWidths.first ?? 0
+ let firstRightIndent = rightLineIndents.first ?? 0
+
+
+ // width of sub node link under node value (i.e. with slashes if any)
+ // aims to center link bars under the value if value is wide enough
+ //
+ // ValueLine: v vv vvvvvv vvvvv
+ // LinkLine: / \ / \ / \ / \
+ //
+ let linkSpacing = min(stringValueWidth, 2 - stringValueWidth % 2)
+ let leftLinkBar = leftNode == nil ? 0 : 1
+ let rightLinkBar = rightNode == nil ? 0 : 1
+ let minLinkWidth = leftLinkBar + linkSpacing + rightLinkBar
+ let valueOffset = (stringValueWidth - linkSpacing) / 2
+
+ // find optimal position for right side top node
+ // * must allow room for link bars above and between left and right top nodes
+ // * must not overlap lower level nodes on any given line (allow gap of minSpacing)
+ // * can be offset to the left if lower subNodes of right node
+ // have no overlap with subNodes of left node
+ let minSpacing = 2
+ let rightNodePosition = zip(leftLineWidths,rightLineIndents[0.. Bool {
+ node.parent?.right == node
+ }
+
+ func isLeftChild(_ node: Node) -> Bool {
+ node.parent?.left == node
+ }
+
+ func sibling(_ node: Node) -> Node? {
+ if isLeftChild(node) {
+ return node.parent?.right
+ } else {
+ return node.parent?.left
+ }
+ }
+
+ final class Node: Equatable {
+ enum Color {
+ case red
+ case black
+ }
+
+ // The length of the text line
+ var length: Int
+ var id: UUID = UUID()
+// var line: TextLine
+
+ // The offset in characters of the entire left subtree
+ var leftSubtreeOffset: Int
+
+ var left: Node?
+ var right: Node?
+ unowned var parent: Node?
+ var color: Color
+
+ init(
+ length: Int,
+// line: TextLine,
+ leftSubtreeOffset: Int,
+ left: Node? = nil,
+ right: Node? = nil,
+ parent: Node? = nil,
+ color: Color
+ ) {
+ self.length = length
+// self.line = line
+ self.leftSubtreeOffset = leftSubtreeOffset
+ self.left = left
+ self.right = right
+ self.parent = parent
+ self.color = color
+ }
+
+ static func == (lhs: Node, rhs: Node) -> Bool {
+ lhs.id == rhs.id
+ }
+
+// func minimum() -> Node? {
+// if let left {
+// return left.minimum()
+// } else {
+// return self
+// }
+// }
+
+// func maximum() -> Node? {
+// if let right {
+// return right.maximum()
+// } else {
+// return self
+// }
+// }
+
+// func getSuccessor() -> Node? {
+// // If node has right child: successor is the min of this right tree
+// if let right {
+// return right.minimum()
+// } else {
+// // Else go upward until node is a left child
+// var currentNode = self
+// var parent = currentNode.parent
+// while currentNode.isRightChild {
+// if let parent = parent {
+// currentNode = parent
+// }
+// parent = currentNode.parent
+// }
+// return parent
+// }
+// }
+
+// func getPredecessor() -> Node? {
+// // If node has left child: successor is the max of this left tree
+// if let left {
+// return left.maximum()
+// } else {
+// // Else go upward until node is a right child
+// var currentNode = self
+// var parent = currentNode.parent
+// while currentNode.isLeftChild {
+// if let parent = parent {
+// currentNode = parent
+// }
+// parent = currentNode.parent
+// }
+// return parent
+// }
+// }
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
new file mode 100644
index 000000000..559171e81
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -0,0 +1,59 @@
+//
+// TextLayoutManager.swift
+//
+//
+// Created by Khan Winter on 6/21/23.
+//
+
+import Foundation
+import AppKit
+
+protocol TextLayoutManagerDelegate: AnyObject { }
+
+class TextLayoutManager: NSObject {
+ private unowned var textStorage: NSTextStorage
+ private var lineStorage: TextLayoutLineStorage
+
+ init(textStorage: NSTextStorage) {
+ self.textStorage = textStorage
+ self.lineStorage = TextLayoutLineStorage()
+ }
+
+ func prepareForDisplay() {
+ guard lineStorage.count == 0 else { return }
+ var string = textStorage.string as String
+ string.makeContiguousUTF8()
+ var info = mach_timebase_info()
+ guard mach_timebase_info(&info) == KERN_SUCCESS else { return }
+
+ let start = mach_absolute_time()
+ var index = 0
+ for (currentIndex, char) in string.lazy.enumerated() {
+ if char == "\n" {
+ lineStorage.insert(atIndex: index, length: currentIndex - index)
+ index = currentIndex
+ }
+ }
+ let end = mach_absolute_time()
+
+ let elapsed = end - start
+
+ let nanos = elapsed * UInt64(info.numer) / UInt64(info.denom)
+ print(TimeInterval(nanos) / TimeInterval(NSEC_PER_MSEC))
+ print(lineStorage.count)
+ }
+
+ func estimatedHeight() -> CGFloat { 0 }
+ func estimatedWidth() -> CGFloat { 0 }
+}
+
+extension TextLayoutManager: NSTextStorageDelegate {
+ func textStorage(
+ _ textStorage: NSTextStorage,
+ didProcessEditing editedMask: NSTextStorageEditActions,
+ range editedRange: NSRange,
+ changeInLength delta: Int
+ ) {
+
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextLine.swift b/Sources/CodeEditTextView/TextView/TextLine.swift
new file mode 100644
index 000000000..7c52be39b
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextLine.swift
@@ -0,0 +1,41 @@
+//
+// TextLine.swift
+//
+//
+// Created by Khan Winter on 6/21/23.
+//
+
+import Foundation
+import AppKit
+
+/// Represents a displayable line of text.
+/// Can be more than one line visually if lines are wrapped.
+final class TextLine {
+ typealias Attributes = [NSAttributedString.Key: Any]
+
+ let id: UUID = UUID()
+ var height: CGFloat = 0
+
+ var ctLines: [CTLine]?
+
+ private let typesetter: Typesetter
+
+ init(typesetter: Typesetter) {
+ self.typesetter = typesetter
+ self.height = 0
+ }
+
+ init() {
+ typesetter = .init(lineBreakMode: .byCharWrapping)
+ }
+
+ func prepareForDisplay(with attributes: [NSRange: Attributes]) {
+
+ }
+}
+
+extension TextLine: Equatable {
+ static func == (lhs: TextLine, rhs: TextLine) -> Bool {
+ lhs.id == rhs.id
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift
new file mode 100644
index 000000000..4c473459f
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextView.swift
@@ -0,0 +1,92 @@
+//
+// TextView.swift
+//
+//
+// Created by Khan Winter on 6/21/23.
+//
+
+import AppKit
+import STTextView
+
+class TextView: NSView {
+ // MARK: - Constants
+
+ enum LineBreakMode {
+ case byCharWrapping
+ case byWordWrapping
+ }
+
+ override var visibleRect: NSRect {
+ if let scrollView = enclosingScrollView {
+ // +200px vertically for a bit of padding
+ return scrollView.visibleRect.insetBy(dx: 0, dy: 400)
+ } else {
+ return super.visibleRect
+ }
+ }
+
+ // MARK: - Configuration
+
+ func setString(_ string: String) {
+ storage.setAttributedString(.init(string: string))
+ }
+
+ // MARK: - Objects
+
+ private var storage: NSTextStorage!
+ private var storageDelegate: MultiStorageDelegate!
+ private var layoutManager: TextLayoutManager!
+
+ // MARK: - Init
+
+ init(string: String) {
+ self.storage = NSTextStorage(string: string)
+ self.storageDelegate = MultiStorageDelegate()
+ self.layoutManager = TextLayoutManager(textStorage: storage)
+
+ storage.delegate = storageDelegate
+ storageDelegate.addDelegate(layoutManager)
+ // TODO: Add Highlighter as storage delegate #2
+
+ super.init(frame: .zero)
+ wantsLayer = true
+ postsFrameChangedNotifications = true
+ postsBoundsChangedNotifications = true
+
+ autoresizingMask = [.width, .height]
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewWillMove(toWindow newWindow: NSWindow?) {
+ super.viewWillMove(toWindow: newWindow)
+ guard newWindow != nil else { return }
+ layoutManager.prepareForDisplay()
+ }
+
+ // MARK: - Draw
+
+ override open var isFlipped: Bool {
+ true
+ }
+
+ override func makeBackingLayer() -> CALayer {
+ let layer = CETiledLayer()
+ layer.tileSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: 1000)
+ layer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
+ return layer
+ }
+
+ override func draw(_ dirtyRect: NSRect) {
+ guard let ctx = NSGraphicsContext.current?.cgContext else { return }
+ ctx.saveGState()
+ ctx.setStrokeColor(NSColor.red.cgColor)
+ ctx.setFillColor(NSColor.orange.cgColor)
+ ctx.setLineWidth(10)
+ ctx.addEllipse(in: dirtyRect)
+ ctx.drawPath(using: .fillStroke)
+ ctx.restoreGState()
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextViewController.swift b/Sources/CodeEditTextView/TextView/TextViewController.swift
new file mode 100644
index 000000000..3871cefee
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextViewController.swift
@@ -0,0 +1,43 @@
+//
+// TextViewController.swift
+//
+//
+// Created by Khan Winter on 6/25/23.
+//
+
+import AppKit
+
+public class TextViewController: NSViewController {
+ var scrollView: NSScrollView!
+ var textView: TextView!
+
+ var string: String
+
+ init(string: String) {
+ self.string = string
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ public override func loadView() {
+ scrollView = NSScrollView()
+ textView = TextView(string: string)
+ textView.frame.size = CGSize(width: 500, height: 100000)
+
+ scrollView.translatesAutoresizingMaskIntoConstraints = false
+ scrollView.hasVerticalScroller = true
+ scrollView.documentView = textView
+
+ self.view = scrollView
+
+ NSLayoutConstraint.activate([
+ scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ scrollView.topAnchor.constraint(equalTo: view.topAnchor),
+ scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
+ ])
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/Typesetter.swift b/Sources/CodeEditTextView/TextView/Typesetter.swift
new file mode 100644
index 000000000..e88fb0a76
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/Typesetter.swift
@@ -0,0 +1,60 @@
+//
+// Typesetter.swift
+//
+//
+// Created by Khan Winter on 6/21/23.
+//
+
+import Foundation
+import CoreText
+
+final class Typesetter {
+ var typesetter: CTTypesetter?
+ var string: NSAttributedString?
+ var lineBreakMode: TextView.LineBreakMode
+
+ // MARK: - Init & Prepare
+
+ init(lineBreakMode: TextView.LineBreakMode) {
+ self.lineBreakMode = lineBreakMode
+ }
+
+ func prepareToTypeset(_ string: NSAttributedString) {
+ self.string = string
+ typesetter = CTTypesetterCreateWithAttributedString(string)
+ }
+
+ // MARK: - Generate lines
+
+ func generateLines() -> [CTLine] {
+ guard let typesetter, let string else {
+ fatalError()
+ }
+
+ var startIndex = 0
+ while startIndex < string.length {
+
+ }
+
+ return []
+ }
+
+ // MARK: - Line Breaks
+
+ private func suggestLineBreak(
+ using typesetter: CTTypesetter,
+ string: NSAttributedString,
+ startingOffset: Int,
+ constrainingWidth: CGFloat
+ ) -> Int {
+ var breakIndex: Int
+ switch lineBreakMode {
+ case .byCharWrapping:
+ breakIndex = CTTypesetterSuggestLineBreak(typesetter, startingOffset, constrainingWidth)
+ case .byWordWrapping:
+ breakIndex = CTTypesetterSuggestClusterBreak(typesetter, startingOffset, constrainingWidth)
+ }
+
+ return 0
+ }
+}
diff --git a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
new file mode 100644
index 000000000..7acc9adbb
--- /dev/null
+++ b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
@@ -0,0 +1,63 @@
+import XCTest
+@testable import CodeEditTextView
+
+final class TextLayoutLineStorageTests: XCTestCase {
+ func test_insert() {
+ let tree = TextLayoutLineStorage()
+ tree.insert(atIndex: 0, length: 1)
+ tree.insert(atIndex: 1, length: 2)
+ tree.insert(atIndex: 1, length: 3)
+ tree.printTree()
+ tree.insert(atIndex: 6, length: 4)
+ tree.printTree()
+ tree.insert(atIndex: 4, length: 5)
+ tree.printTree()
+ tree.insert(atIndex: 1, length: 6)
+ tree.printTree()
+ tree.insert(atIndex: 0, length: 7)
+ tree.printTree()
+ tree.insert(atIndex: 28, length: 8)
+ tree.printTree()
+ tree.insert(atIndex: 36, length: 9)
+ tree.printTree()
+ tree.insert(atIndex: 45, length: 10)
+ tree.printTree()
+ tree.insert(atIndex: 55, length: 11)
+ tree.printTree()
+ tree.insert(atIndex: 66, length: 12)
+ tree.printTree()
+
+ tree.update(atIndex: 18, delta: 2)
+ tree.printTree()
+
+ tree.update(atIndex: 28, delta: -2)
+ tree.printTree()
+
+// print(tree.search(for: 7)?.length)
+// print(tree.search(for: 17)?.length)
+// print(tree.search(for: 0)?.length)
+
+// var n = tree.root?.minimum()
+// while let node = n {
+// print("\(node.length)", terminator: "")
+// n = node.getSuccessor()
+// }
+ print("")
+ }
+
+ func test_insertInc() {
+ let tree = TextLayoutLineStorage()
+ for i in 0..<100_000 {
+ tree.insert(atIndex: i, length: 1)
+ }
+ }
+
+ func test_insertPerformance() {
+ let tree = TextLayoutLineStorage()
+ measure {
+ for i in 0..<250_000 {
+ tree.insert(atIndex: i, length: 1)
+ }
+ }
+ }
+}
From f70e24d3b814300cf102033620b773f1cb3e9035 Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Sat, 15 Jul 2023 10:41:01 -0500
Subject: [PATCH 02/75] Add height to textline tree
---
...ABF49830-0162-46D3-AAD0-396278570495.plist | 2 +-
.../TextLayoutManager/TextLayoutManager.swift | 46 ++++++---
.../TextView/TextLayoutManager/TextLine.swift | 29 ++++++
.../TextLineStorage+Node.swift} | 18 ++--
.../TextLineStorage.swift} | 93 ++++++++++---------
.../Typesetter/LineFragment.swift | 20 ++++
.../Typesetter/Typesetter.swift | 80 ++++++++++++++++
.../CodeEditTextView/TextView/TextLine.swift | 41 --------
.../CodeEditTextView/TextView/TextView.swift | 19 ++--
.../TextView/Typesetter.swift | 60 ------------
.../TextLayoutLineStorageTests.swift | 46 ++++-----
11 files changed, 259 insertions(+), 195 deletions(-)
create mode 100644 Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
rename Sources/CodeEditTextView/TextView/TextLayoutManager/{TextLayoutLineStorage/TextLayoutLineStorageNode.swift => TextLineStorage/TextLineStorage+Node.swift} (89%)
rename Sources/CodeEditTextView/TextView/TextLayoutManager/{TextLayoutLineStorage/TextLayoutLineStorage.swift => TextLineStorage/TextLineStorage.swift} (87%)
create mode 100644 Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/LineFragment.swift
create mode 100644 Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/Typesetter.swift
delete mode 100644 Sources/CodeEditTextView/TextView/TextLine.swift
delete mode 100644 Sources/CodeEditTextView/TextView/Typesetter.swift
diff --git a/.swiftpm/xcode/xcshareddata/xcbaselines/CodeEditTextViewTests.xcbaseline/ABF49830-0162-46D3-AAD0-396278570495.plist b/.swiftpm/xcode/xcshareddata/xcbaselines/CodeEditTextViewTests.xcbaseline/ABF49830-0162-46D3-AAD0-396278570495.plist
index 9080d8c2b..8acacc189 100644
--- a/.swiftpm/xcode/xcshareddata/xcbaselines/CodeEditTextViewTests.xcbaseline/ABF49830-0162-46D3-AAD0-396278570495.plist
+++ b/.swiftpm/xcode/xcshareddata/xcbaselines/CodeEditTextViewTests.xcbaseline/ABF49830-0162-46D3-AAD0-396278570495.plist
@@ -11,7 +11,7 @@
com.apple.XCTPerformanceMetric_WallClockTime
baselineAverage
- 2.030000
+ 0.933000
baselineIntegrationDisplayName
Local Baseline
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
index 559171e81..f09e6d5d5 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -12,35 +12,51 @@ protocol TextLayoutManagerDelegate: AnyObject { }
class TextLayoutManager: NSObject {
private unowned var textStorage: NSTextStorage
- private var lineStorage: TextLayoutLineStorage
+ private var lineStorage: TextLineStorage
init(textStorage: NSTextStorage) {
self.textStorage = textStorage
- self.lineStorage = TextLayoutLineStorage()
+ self.lineStorage = TextLineStorage()
+
}
- func prepareForDisplay() {
+ private func prepareTextLines() {
guard lineStorage.count == 0 else { return }
- var string = textStorage.string as String
- string.makeContiguousUTF8()
var info = mach_timebase_info()
guard mach_timebase_info(&info) == KERN_SUCCESS else { return }
-
let start = mach_absolute_time()
- var index = 0
- for (currentIndex, char) in string.lazy.enumerated() {
- if char == "\n" {
- lineStorage.insert(atIndex: index, length: currentIndex - index)
- index = currentIndex
+
+ func getNextLine(in text: NSString, startingAt location: Int) -> NSRange? {
+ let range = NSRange(location: location, length: 0)
+ var end: Int = NSNotFound
+ var contentsEnd: Int = NSNotFound
+ text.getLineStart(nil, end: &end, contentsEnd: &contentsEnd, for: range)
+ if end != NSNotFound && contentsEnd != NSNotFound && end != contentsEnd {
+ return NSRange(location: contentsEnd, length: end - contentsEnd)
+ } else {
+ return nil
}
}
- let end = mach_absolute_time()
+ var index = 0
+ var newlineIndexes: [Int] = []
+ while let range = getNextLine(in: textStorage.mutableString, startingAt: index) {
+ index = NSMaxRange(range)
+ newlineIndexes.append(index)
+ }
- let elapsed = end - start
+ for idx in 0.. CGFloat { 0 }
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
new file mode 100644
index 000000000..4f9c56092
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
@@ -0,0 +1,29 @@
+//
+// TextLine.swift
+//
+//
+// Created by Khan Winter on 6/21/23.
+//
+
+import Foundation
+import AppKit
+
+/// Represents a displayable line of text.
+final class TextLine {
+ typealias Attributes = [NSAttributedString.Key: Any]
+
+ var stringRef: NSString
+ var range: NSRange
+ private let typesetter: Typesetter = .init()
+
+ init(stringRef: NSString, range: NSRange) {
+ self.stringRef = stringRef
+ self.range = range
+ }
+
+ func prepareForDisplay(with attributes: [NSRange: Attributes], maxWidth: CGFloat) {
+ let string = NSAttributedString(string: stringRef.substring(with: range))
+ typesetter.prepareToTypeset(string)
+ typesetter.generateLines(maxWidth: maxWidth)
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutLineStorage/TextLayoutLineStorageNode.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Node.swift
similarity index 89%
rename from Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutLineStorage/TextLayoutLineStorageNode.swift
rename to Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Node.swift
index f66fd75eb..690427717 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutLineStorage/TextLayoutLineStorageNode.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Node.swift
@@ -1,5 +1,5 @@
//
-// File.swift
+// TextLineStorage+Node.swift
//
//
// Created by Khan Winter on 6/25/23.
@@ -7,7 +7,7 @@
import Foundation
-extension TextLayoutLineStorage {
+extension TextLineStorage {
func isRightChild(_ node: Node) -> Bool {
node.parent?.right == node
}
@@ -33,10 +33,12 @@ extension TextLayoutLineStorage {
// The length of the text line
var length: Int
var id: UUID = UUID()
-// var line: TextLine
+ var line: TextLine
// The offset in characters of the entire left subtree
var leftSubtreeOffset: Int
+ var leftSubtreeHeight: CGFloat
+ var height: CGFloat
var left: Node?
var right: Node?
@@ -45,16 +47,20 @@ extension TextLayoutLineStorage {
init(
length: Int,
-// line: TextLine,
+ line: TextLine,
leftSubtreeOffset: Int,
+ leftSubtreeHeight: CGFloat,
+ height: CGFloat,
left: Node? = nil,
right: Node? = nil,
parent: Node? = nil,
color: Color
) {
self.length = length
-// self.line = line
+ self.line = line
self.leftSubtreeOffset = leftSubtreeOffset
+ self.leftSubtreeHeight = leftSubtreeHeight
+ self.height = height
self.left = left
self.right = right
self.parent = parent
@@ -64,7 +70,7 @@ extension TextLayoutLineStorage {
static func == (lhs: Node, rhs: Node) -> Bool {
lhs.id == rhs.id
}
-
+
// func minimum() -> Node? {
// if let left {
// return left.minimum()
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutLineStorage/TextLayoutLineStorage.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage.swift
similarity index 87%
rename from Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutLineStorage/TextLayoutLineStorage.swift
rename to Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage.swift
index ccb78831a..1f34bbb75 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutLineStorage/TextLayoutLineStorage.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage.swift
@@ -8,7 +8,7 @@
import Foundation
/// Implements a red-black tree for efficiently editing, storing and retrieving `TextLine`s.
-final class TextLayoutLineStorage {
+final class TextLineStorage {
#if DEBUG
var root: Node?
#else
@@ -27,13 +27,21 @@ final class TextLayoutLineStorage {
/// - Parameters:
/// - line: The text line to insert
/// - range: The range the line represents. If the range is empty the line will be ignored.
- public func insert(atIndex index: Int, length: Int) {
+ public func insert(line: TextLine, atIndex index: Int, length: Int, height: CGFloat) {
assert(index >= 0 && index <= self.length, "Invalid index, expected between 0 and \(self.length). Got \(index)")
defer {
+ self.count += 1
self.length += length
}
- let insertedNode = Node(length: length, leftSubtreeOffset: 0, color: .black)
+ let insertedNode = Node(
+ length: length,
+ line: line,
+ leftSubtreeOffset: 0,
+ leftSubtreeHeight: 0.0,
+ height: height,
+ color: .black
+ )
guard root != nil else {
root = insertedNode
return
@@ -86,35 +94,28 @@ final class TextLayoutLineStorage {
/// Complexity: `O(m log n)` where `m` is the number of lines that need to be deleted as a result of this update.
/// and `n` is the number of lines stored in the tree.
///
- /// Lines will be deleted if the delta is both negative and encompases the entire line.
+ /// Lines will be deleted if the delta is both negative and encompasses the entire line.
+ ///
+ /// If the delta goes beyond the line's range, an error will be thrown.
/// - Parameters:
- /// - index: The indice where the edit began
+ /// - index: The index where the edit began
/// - delta: The change in length of the document. Negative for deletes, positive for insertions.
- public func update(atIndex index: Int, delta: Int) {
+ /// - deltaHeight: The change in height of the document.
+ public func update(atIndex index: Int, delta: Int, deltaHeight: CGFloat) {
assert(index >= 0 && index < self.length, "Invalid index, expected between 0 and \(self.length). Got \(index)")
assert(delta != 0, "Delta must be non-0")
+ let (node, offset) = search(for: index)
+ guard let node, offset > -1 else { return }
if delta < 0 {
- var remainingDelta = -1 * delta
- var index = index
- while remainingDelta > 0 {
- let (node, offset) = search(for: index)
- guard let node, offset > -1 else { return }
- let nodeDelta = index - offset
- node.length -= min(remainingDelta, nodeDelta)
- remainingDelta -= min(remainingDelta, nodeDelta)
- index = offset - 1
-
- metaFixup(startingAt: node, delta: -nodeDelta)
- if node.length == 0 {
-
- }
- }
- } else {
- let (node, offset) = search(for: index)
- guard let node, offset > -1 else { return }
- node.length += delta
- metaFixup(startingAt: node, delta: delta)
+ assert(
+ index - offset > delta,
+ "Delta too large. Deleting \(-delta) from line at position \(index) extends beyond the line's range."
+ )
}
+
+ node.length += delta
+ node.height += deltaHeight
+ metaFixup(startingAt: node, delta: delta, deltaHeight: deltaHeight)
}
/// Deletes a line at the given index.
@@ -124,9 +125,9 @@ final class TextLayoutLineStorage {
/// - Parameter index: The index to delete a line at.
public func delete(lineAt index: Int) {
assert(index >= 0 && index < self.length, "Invalid index, expected between 0 and \(self.length). Got \(index)")
- guard let nodeZ = search(for: index).0 else { return }
- var nodeX: Node
- var nodeY: Node
+// guard let nodeZ = search(for: index).0 else { return }
+// var nodeX: Node
+// var nodeY: Node
}
@@ -143,7 +144,7 @@ final class TextLayoutLineStorage {
}
}
-private extension TextLayoutLineStorage {
+private extension TextLineStorage {
// MARK: - Search
/// Searches for the given index. Returns a node and offset if found.
@@ -174,7 +175,7 @@ private extension TextLayoutLineStorage {
// MARK: - Fixup
func insertFixup(node: Node) {
- metaFixup(startingAt: node, delta: node.length)
+ metaFixup(startingAt: node, delta: node.length, deltaHeight: node.height)
var nextNode: Node? = node
while var nodeX = nextNode, nodeX != root, let nodeXParent = nodeX.parent, nodeXParent.color == .red {
@@ -227,25 +228,31 @@ private extension TextLayoutLineStorage {
}
/// Walk up the tree, updating any `leftSubtree` metadata.
- func metaFixup(startingAt node: Node, delta: Int) {
+ func metaFixup(startingAt node: Node, delta: Int, deltaHeight: CGFloat) {
guard node.parent != nil, delta > 0 else { return }
// Find the first node that needs to be updated (first left child)
var nodeX: Node? = node
- while nodeX != nil, isRightChild(nodeX!) && nodeX!.parent != nil {
- nodeX = nodeX?.parent
+ var nodeXParent: Node? = node.parent
+ while nodeX != nil {
+ if nodeXParent?.right == nodeX {
+ nodeX = nodeXParent
+ nodeXParent = nodeX?.parent
+ } else {
+ nodeX = nil
+ }
}
- guard var nodeX else { return }
- var count = 0
+ guard nodeX != nil else { return }
while nodeX != root {
- if isLeftChild(nodeX) {
- nodeX.parent?.leftSubtreeOffset += delta
+ if nodeXParent?.left == nodeX {
+ nodeXParent?.leftSubtreeOffset += delta
+ nodeXParent?.leftSubtreeHeight += deltaHeight
}
- if let parent = nodeX.parent {
+ if nodeXParent != nil {
count += 1
- nodeX = parent
+ nodeX = nodeXParent
+ nodeXParent = nodeX?.parent
} else {
- print("Meta Fixup, len: \(length), num items touched: \(count)")
return
}
}
@@ -259,7 +266,7 @@ private extension TextLayoutLineStorage {
// MARK: - Rotations
-private extension TextLayoutLineStorage {
+private extension TextLineStorage {
func rightRotate(node: Node) {
rotate(node: node, left: false)
}
@@ -274,6 +281,7 @@ private extension TextLayoutLineStorage {
if left {
nodeY = node.right
nodeY?.leftSubtreeOffset += node.leftSubtreeOffset + node.length
+ nodeY?.leftSubtreeHeight += node.leftSubtreeHeight + node.height
node.right = nodeY?.left
node.right?.parent = node
} else {
@@ -298,6 +306,7 @@ private extension TextLayoutLineStorage {
} else {
nodeY?.right = node
node.leftSubtreeOffset = (node.left?.length ?? 0) + (node.left?.leftSubtreeOffset ?? 0)
+ node.leftSubtreeHeight = (node.left?.height ?? 0) + (node.left?.leftSubtreeHeight ?? 0)
}
node.parent = nodeY
}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/LineFragment.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/LineFragment.swift
new file mode 100644
index 000000000..8e37a90f2
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/LineFragment.swift
@@ -0,0 +1,20 @@
+//
+// LineFragment.swift
+//
+//
+// Created by Khan Winter on 6/29/23.
+//
+
+import AppKit
+
+final class LineFragment {
+ var ctLine: CTLine
+ var width: CGFloat
+ var height: CGFloat
+
+ init(ctLine: CTLine, width: CGFloat, height: CGFloat) {
+ self.ctLine = ctLine
+ self.width = width
+ self.height = height
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/Typesetter.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/Typesetter.swift
new file mode 100644
index 000000000..41eaebd39
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/Typesetter.swift
@@ -0,0 +1,80 @@
+//
+// Typesetter.swift
+//
+//
+// Created by Khan Winter on 6/21/23.
+//
+
+import Foundation
+import CoreText
+
+final class Typesetter {
+ var typesetter: CTTypesetter?
+ var string: NSAttributedString!
+ var lineFragments: [LineFragment] = []
+
+ // MARK: - Init & Prepare
+
+ init() { }
+
+ func prepareToTypeset(_ string: NSAttributedString) {
+ self.typesetter = CTTypesetterCreateWithAttributedString(string)
+ self.string = string
+ }
+
+ // MARK: - Generate lines
+
+ func generateLines(maxWidth: CGFloat) {
+ guard let typesetter else { return }
+ var startIndex = 0
+ var lineBreak = suggestLineBreak(using: typesetter, startingOffset: startIndex, constrainingWidth: maxWidth)
+ while lineBreak < string.length - 1 {
+ lineFragments.append(typesetLine(range: NSRange(location: startIndex, length: lineBreak)))
+ startIndex = lineBreak
+ }
+ }
+
+ private func typesetLine(range: NSRange) -> LineFragment {
+ let ctLine = CTTypesetterCreateLine(typesetter!, CFRangeMake(range.location, range.length))
+ var ascent: CGFloat = 0
+ var descent: CGFloat = 0
+ var leading: CGFloat = 0
+ let width = CGFloat(CTLineGetTypographicBounds(ctLine, &ascent, &descent, &leading))
+ let height = ascent + descent + leading
+ return LineFragment(ctLine: ctLine, width: width, height: height)
+ }
+
+ // MARK: - Line Breaks
+
+ private func suggestLineBreak(
+ using typesetter: CTTypesetter,
+ startingOffset: Int,
+ constrainingWidth: CGFloat
+ ) -> Int {
+ var breakIndex: Int
+ breakIndex = startingOffset + CTTypesetterSuggestClusterBreak(typesetter, startingOffset, constrainingWidth)
+ // Ensure we're breaking at a whitespace, CT can sometimes suggest this incorrectly.
+ guard breakIndex < string.length ||
+ (breakIndex - 1 > 0 && ensureCharacterCanBreakLine(at: breakIndex - 1)) else {
+ return breakIndex
+ }
+
+ // Find a real break index at the closest whitespace/punctuation character
+ var index = breakIndex - 1
+ while index > 0 && breakIndex - index > 100 {
+ if ensureCharacterCanBreakLine(at: index) {
+ return index
+ }
+ index -= 1
+ }
+
+ return breakIndex
+ }
+
+ private func ensureCharacterCanBreakLine(at index: Int) -> Bool {
+ let set = CharacterSet(
+ charactersIn: string.attributedSubstring(from: NSRange(location: index, length: 1)).string
+ )
+ return set.isSubset(of: .whitespacesWithoutNewlines) || set.isSubset(of: .punctuationCharacters)
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextLine.swift b/Sources/CodeEditTextView/TextView/TextLine.swift
deleted file mode 100644
index 7c52be39b..000000000
--- a/Sources/CodeEditTextView/TextView/TextLine.swift
+++ /dev/null
@@ -1,41 +0,0 @@
-//
-// TextLine.swift
-//
-//
-// Created by Khan Winter on 6/21/23.
-//
-
-import Foundation
-import AppKit
-
-/// Represents a displayable line of text.
-/// Can be more than one line visually if lines are wrapped.
-final class TextLine {
- typealias Attributes = [NSAttributedString.Key: Any]
-
- let id: UUID = UUID()
- var height: CGFloat = 0
-
- var ctLines: [CTLine]?
-
- private let typesetter: Typesetter
-
- init(typesetter: Typesetter) {
- self.typesetter = typesetter
- self.height = 0
- }
-
- init() {
- typesetter = .init(lineBreakMode: .byCharWrapping)
- }
-
- func prepareForDisplay(with attributes: [NSRange: Attributes]) {
-
- }
-}
-
-extension TextLine: Equatable {
- static func == (lhs: TextLine, rhs: TextLine) -> Bool {
- lhs.id == rhs.id
- }
-}
diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift
index 4c473459f..3309b1e21 100644
--- a/Sources/CodeEditTextView/TextView/TextView.swift
+++ b/Sources/CodeEditTextView/TextView/TextView.swift
@@ -8,14 +8,19 @@
import AppKit
import STTextView
+/**
+
+```
+ TextView
+ |-> LayoutManager Creates and manages TextLines from the text storage
+ | |-> [TextLine] Represents a text line
+ | | |-> Typesetter Lays out and calculates line fragments
+ | | | |-> [LineFragment] Represents a visual text line (may be multiple if text wrapping is on)
+ |-> SelectionManager (depends on LayoutManager) Maintains text selections and renders selections
+ | |-> [TextSelection]
+ ```
+ */
class TextView: NSView {
- // MARK: - Constants
-
- enum LineBreakMode {
- case byCharWrapping
- case byWordWrapping
- }
-
override var visibleRect: NSRect {
if let scrollView = enclosingScrollView {
// +200px vertically for a bit of padding
diff --git a/Sources/CodeEditTextView/TextView/Typesetter.swift b/Sources/CodeEditTextView/TextView/Typesetter.swift
deleted file mode 100644
index e88fb0a76..000000000
--- a/Sources/CodeEditTextView/TextView/Typesetter.swift
+++ /dev/null
@@ -1,60 +0,0 @@
-//
-// Typesetter.swift
-//
-//
-// Created by Khan Winter on 6/21/23.
-//
-
-import Foundation
-import CoreText
-
-final class Typesetter {
- var typesetter: CTTypesetter?
- var string: NSAttributedString?
- var lineBreakMode: TextView.LineBreakMode
-
- // MARK: - Init & Prepare
-
- init(lineBreakMode: TextView.LineBreakMode) {
- self.lineBreakMode = lineBreakMode
- }
-
- func prepareToTypeset(_ string: NSAttributedString) {
- self.string = string
- typesetter = CTTypesetterCreateWithAttributedString(string)
- }
-
- // MARK: - Generate lines
-
- func generateLines() -> [CTLine] {
- guard let typesetter, let string else {
- fatalError()
- }
-
- var startIndex = 0
- while startIndex < string.length {
-
- }
-
- return []
- }
-
- // MARK: - Line Breaks
-
- private func suggestLineBreak(
- using typesetter: CTTypesetter,
- string: NSAttributedString,
- startingOffset: Int,
- constrainingWidth: CGFloat
- ) -> Int {
- var breakIndex: Int
- switch lineBreakMode {
- case .byCharWrapping:
- breakIndex = CTTypesetterSuggestLineBreak(typesetter, startingOffset, constrainingWidth)
- case .byWordWrapping:
- breakIndex = CTTypesetterSuggestClusterBreak(typesetter, startingOffset, constrainingWidth)
- }
-
- return 0
- }
-}
diff --git a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
index 7acc9adbb..5959e937a 100644
--- a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
+++ b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
@@ -4,27 +4,27 @@ import XCTest
final class TextLayoutLineStorageTests: XCTestCase {
func test_insert() {
let tree = TextLayoutLineStorage()
- tree.insert(atIndex: 0, length: 1)
- tree.insert(atIndex: 1, length: 2)
- tree.insert(atIndex: 1, length: 3)
- tree.printTree()
- tree.insert(atIndex: 6, length: 4)
- tree.printTree()
- tree.insert(atIndex: 4, length: 5)
- tree.printTree()
- tree.insert(atIndex: 1, length: 6)
- tree.printTree()
- tree.insert(atIndex: 0, length: 7)
- tree.printTree()
- tree.insert(atIndex: 28, length: 8)
- tree.printTree()
- tree.insert(atIndex: 36, length: 9)
- tree.printTree()
- tree.insert(atIndex: 45, length: 10)
- tree.printTree()
- tree.insert(atIndex: 55, length: 11)
- tree.printTree()
- tree.insert(atIndex: 66, length: 12)
+// tree.insert(atIndex: 0, length: 1)
+// tree.insert(atIndex: 1, length: 2)
+// tree.insert(atIndex: 1, length: 3)
+// tree.printTree()
+// tree.insert(atIndex: 6, length: 4)
+// tree.printTree()
+// tree.insert(atIndex: 4, length: 5)
+// tree.printTree()
+// tree.insert(atIndex: 1, length: 6)
+// tree.printTree()
+// tree.insert(atIndex: 0, length: 7)
+// tree.printTree()
+// tree.insert(atIndex: 28, length: 8)
+// tree.printTree()
+// tree.insert(atIndex: 36, length: 9)
+// tree.printTree()
+// tree.insert(atIndex: 45, length: 10)
+// tree.printTree()
+// tree.insert(atIndex: 55, length: 11)
+// tree.printTree()
+// tree.insert(atIndex: 66, length: 12)
tree.printTree()
tree.update(atIndex: 18, delta: 2)
@@ -48,7 +48,7 @@ final class TextLayoutLineStorageTests: XCTestCase {
func test_insertInc() {
let tree = TextLayoutLineStorage()
for i in 0..<100_000 {
- tree.insert(atIndex: i, length: 1)
+ tree.insert(line: .init(), atIndex: i, length: 1)
}
}
@@ -56,7 +56,7 @@ final class TextLayoutLineStorageTests: XCTestCase {
let tree = TextLayoutLineStorage()
measure {
for i in 0..<250_000 {
- tree.insert(atIndex: i, length: 1)
+ tree.insert(line: .init(), atIndex: i, length: 1)
}
}
}
From 2776c2e452dd63e33b0ae8a053387e2aeffb1328 Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Sun, 16 Jul 2023 00:14:49 -0500
Subject: [PATCH 03/75] Get something rendering...
---
...ABF49830-0162-46D3-AAD0-396278570495.plist | 10 +
.../TextView/CETiledLayer.swift | 8 +-
.../TextLayoutManager/TextLayoutManager.swift | 116 ++++++++++--
.../TextView/TextLayoutManager/TextLine.swift | 13 +-
.../TextLineStorage+Cache.swift | 14 ++
.../TextLineStorage+Node.swift | 80 ++++----
.../TextLineStorage/TextLineStorage.swift | 174 +++++++++++++-----
.../Typesetter/Typesetter.swift | 9 +-
.../CodeEditTextView/TextView/TextView.swift | 35 +++-
.../TextView/TextViewController.swift | 5 +-
.../TextLayoutLineStorageTests.swift | 88 ++++-----
11 files changed, 375 insertions(+), 177 deletions(-)
create mode 100644 Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Cache.swift
diff --git a/.swiftpm/xcode/xcshareddata/xcbaselines/CodeEditTextViewTests.xcbaseline/ABF49830-0162-46D3-AAD0-396278570495.plist b/.swiftpm/xcode/xcshareddata/xcbaselines/CodeEditTextViewTests.xcbaseline/ABF49830-0162-46D3-AAD0-396278570495.plist
index 8acacc189..3b739b85f 100644
--- a/.swiftpm/xcode/xcshareddata/xcbaselines/CodeEditTextViewTests.xcbaseline/ABF49830-0162-46D3-AAD0-396278570495.plist
+++ b/.swiftpm/xcode/xcshareddata/xcbaselines/CodeEditTextViewTests.xcbaseline/ABF49830-0162-46D3-AAD0-396278570495.plist
@@ -6,6 +6,16 @@
TextLayoutLineStorageTests
+ test_insertFastPerformance()
+
+ com.apple.XCTPerformanceMetric_WallClockTime
+
+ baselineAverage
+ 0.232242
+ baselineIntegrationDisplayName
+ Local Baseline
+
+
test_insertPerformance()
com.apple.XCTPerformanceMetric_WallClockTime
diff --git a/Sources/CodeEditTextView/TextView/CETiledLayer.swift b/Sources/CodeEditTextView/TextView/CETiledLayer.swift
index 760d305f7..941c8d344 100644
--- a/Sources/CodeEditTextView/TextView/CETiledLayer.swift
+++ b/Sources/CodeEditTextView/TextView/CETiledLayer.swift
@@ -18,13 +18,13 @@ class CETiledLayer: CATiledLayer {
/// A dictionary containing layer actions.
/// Disable animations
- override public var actions: [String : CAAction]? {
- set {
- return
- }
+ override public var actions: [String: CAAction]? {
get {
super.actions
}
+ set {
+ return
+ }
}
public override init() {
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
index f09e6d5d5..7128250a0 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -13,45 +13,68 @@ protocol TextLayoutManagerDelegate: AnyObject { }
class TextLayoutManager: NSObject {
private unowned var textStorage: NSTextStorage
private var lineStorage: TextLineStorage
+ public var typingAttributes: [NSAttributedString.Key: Any] = [
+ .font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular),
+ .paragraphStyle: NSParagraphStyle.default.copy()
+ ]
- init(textStorage: NSTextStorage) {
+ // MARK: - Init
+
+ /// Initialize a text layout manager and prepare it for use.
+ /// - Parameters:
+ /// - textStorage: The text storage object to use as a data source.
+ /// - typingAttributes: The attributes to use while typing.
+ init(textStorage: NSTextStorage, typingAttributes: [NSAttributedString.Key: Any]) {
self.textStorage = textStorage
self.lineStorage = TextLineStorage()
-
+ self.typingAttributes = typingAttributes
+ super.init()
+ textStorage.addAttributes(typingAttributes, range: NSRange(location: 0, length: textStorage.length))
+ prepareTextLines()
}
+ /// Prepares the layout manager for use.
+ /// Parses the text storage object into lines and builds the `lineStorage` object from those lines.
private func prepareTextLines() {
guard lineStorage.count == 0 else { return }
var info = mach_timebase_info()
guard mach_timebase_info(&info) == KERN_SUCCESS else { return }
let start = mach_absolute_time()
- func getNextLine(in text: NSString, startingAt location: Int) -> NSRange? {
+ func getNextLine(startingAt location: Int) -> NSRange? {
let range = NSRange(location: location, length: 0)
var end: Int = NSNotFound
var contentsEnd: Int = NSNotFound
- text.getLineStart(nil, end: &end, contentsEnd: &contentsEnd, for: range)
+ (textStorage.string as NSString).getLineStart(nil, end: &end, contentsEnd: &contentsEnd, for: range)
if end != NSNotFound && contentsEnd != NSNotFound && end != contentsEnd {
return NSRange(location: contentsEnd, length: end - contentsEnd)
} else {
return nil
}
}
+
+ let estimatedLineHeight = NSAttributedString(string: " ", attributes: typingAttributes).boundingRect(
+ with: NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
+ ).height
+
var index = 0
- var newlineIndexes: [Int] = []
- while let range = getNextLine(in: textStorage.mutableString, startingAt: index) {
+ var lines: [(TextLine, Int)] = []
+ while let range = getNextLine(startingAt: index) {
+ lines.append((
+ TextLine(stringRef: textStorage, range: NSRange(location: index, length: NSMaxRange(range) - index)),
+ NSMaxRange(range) - index
+ ))
index = NSMaxRange(range)
- newlineIndexes.append(index)
}
-
- for idx in 0.. 0 {
+ lines.append((
+ TextLine(stringRef: textStorage, range: NSRange(location: index, length: textStorage.length - index)),
+ index
+ ))
}
- /*
- let line = TextLine(stringRef: textStorage.mutableString, range: range)
- lineStorage.insert(line: line, atIndex: index, length: NSMaxRange(range) - index)
- */
+ lineStorage.build(from: lines, estimatedLineHeight: estimatedLineHeight)
let end = mach_absolute_time()
let elapsed = end - start
@@ -59,8 +82,71 @@ class TextLayoutManager: NSObject {
print("Layout Manager built in: ", TimeInterval(nanos) / TimeInterval(NSEC_PER_MSEC), "ms")
}
- func estimatedHeight() -> CGFloat { 0 }
+ // MARK: - API
+
+ func estimatedHeight() -> CGFloat {
+ guard let position = lineStorage.getLine(atIndex: lineStorage.length - 1) else {
+ return 0.0
+ }
+ return position.node.height + position.height
+ }
func estimatedWidth() -> CGFloat { 0 }
+
+ func textLineForPosition(_ posY: CGFloat) -> TextLine? {
+ lineStorage.getLine(atPosition: posY)?.node.line
+ }
+
+ // MARK: - Rendering
+
+ func draw(inRect rect: CGRect, context: CGContext) {
+ // Get all lines in rect & draw!
+ var currentPosition = lineStorage.getLine(atPosition: rect.minY)
+ while let position = currentPosition, position.height < rect.maxY {
+ let lineHeight = drawLine(
+ line: position.node.line,
+ offsetHeight: position.height,
+ minY: rect.minY,
+ maxY: rect.maxY,
+ context: context
+ )
+ if lineHeight != position.node.height {
+ lineStorage.update(atIndex: position.offset, delta: 0, deltaHeight: lineHeight - position.node.height)
+ }
+ currentPosition = lineStorage.getLine(atIndex: position.offset + position.node.length)
+ }
+ }
+
+ /// Draws a `TextLine` into the current graphics context up to a maximum y position.
+ /// - Parameters:
+ /// - line: The line to draw.
+ /// - offsetHeight: The initial offset of the line.
+ /// - minY: The minimum Y position to begin drawing from.
+ /// - maxY: The maximum Y position to draw to.
+ private func drawLine(
+ line: TextLine,
+ offsetHeight: CGFloat,
+ minY: CGFloat,
+ maxY: CGFloat,
+ context: CGContext
+ ) -> CGFloat {
+ if line.typesetter.lineFragments.isEmpty {
+ line.prepareForDisplay(maxWidth: .greatestFiniteMagnitude)
+ }
+ var height = offsetHeight
+ for lineFragment in line.typesetter.lineFragments {
+ if height + lineFragment.height >= minY {
+ // The fragment is within the valid region
+ context.saveGState()
+ context.textMatrix = .init(scaleX: 1, y: -1)
+ context.translateBy(x: 0, y: lineFragment.height)
+ context.textPosition = CGPoint(x: 0, y: height)
+ CTLineDraw(lineFragment.ctLine, context)
+ context.restoreGState()
+ }
+ height += lineFragment.height
+ }
+ return height - offsetHeight
+ }
}
extension TextLayoutManager: NSTextStorageDelegate {
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
index 4f9c56092..d6bf81704 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
@@ -12,18 +12,17 @@ import AppKit
final class TextLine {
typealias Attributes = [NSAttributedString.Key: Any]
- var stringRef: NSString
+ unowned var stringRef: NSTextStorage
var range: NSRange
- private let typesetter: Typesetter = .init()
+ let typesetter: Typesetter = Typesetter()
- init(stringRef: NSString, range: NSRange) {
+ init(stringRef: NSTextStorage, range: NSRange) {
self.stringRef = stringRef
self.range = range
}
- func prepareForDisplay(with attributes: [NSRange: Attributes], maxWidth: CGFloat) {
- let string = NSAttributedString(string: stringRef.substring(with: range))
- typesetter.prepareToTypeset(string)
- typesetter.generateLines(maxWidth: maxWidth)
+ func prepareForDisplay(maxWidth: CGFloat) {
+ let string = stringRef.attributedSubstring(from: range)
+ typesetter.prepareToTypeset(string, maxWidth: maxWidth)
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Cache.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Cache.swift
new file mode 100644
index 000000000..0efbf26a5
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Cache.swift
@@ -0,0 +1,14 @@
+//
+// TextLineStorage+Cache.swift
+//
+//
+// Created by Khan Winter on 7/15/23.
+//
+
+import Foundation
+
+extension TextLineStorage {
+ class Cache {
+ // TODO: Cache nodes for efficient fetching & updating
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Node.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Node.swift
index 690427717..05c918444 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Node.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Node.swift
@@ -71,56 +71,38 @@ extension TextLineStorage {
lhs.id == rhs.id
}
-// func minimum() -> Node? {
-// if let left {
-// return left.minimum()
-// } else {
-// return self
-// }
-// }
-
-// func maximum() -> Node? {
-// if let right {
-// return right.maximum()
-// } else {
-// return self
-// }
-// }
+ func minimum() -> Node? {
+ if let left {
+ return left.minimum()
+ } else {
+ return self
+ }
+ }
-// func getSuccessor() -> Node? {
-// // If node has right child: successor is the min of this right tree
-// if let right {
-// return right.minimum()
-// } else {
-// // Else go upward until node is a left child
-// var currentNode = self
-// var parent = currentNode.parent
-// while currentNode.isRightChild {
-// if let parent = parent {
-// currentNode = parent
-// }
-// parent = currentNode.parent
-// }
-// return parent
-// }
-// }
+ func maximum() -> Node? {
+ if let right {
+ return right.maximum()
+ } else {
+ return self
+ }
+ }
-// func getPredecessor() -> Node? {
-// // If node has left child: successor is the max of this left tree
-// if let left {
-// return left.maximum()
-// } else {
-// // Else go upward until node is a right child
-// var currentNode = self
-// var parent = currentNode.parent
-// while currentNode.isLeftChild {
-// if let parent = parent {
-// currentNode = parent
-// }
-// parent = currentNode.parent
-// }
-// return parent
-// }
-// }
+ func getSuccessor() -> Node? {
+ // If node has right child: successor is the min of this right tree
+ if let right {
+ return right.minimum()
+ } else {
+ // Else go upward until node is a left child
+ var currentNode = self
+ var parent = currentNode.parent
+ while currentNode.parent?.right == currentNode {
+ if let parent = parent {
+ currentNode = parent
+ }
+ parent = currentNode.parent
+ }
+ return parent
+ }
+ }
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage.swift
index 1f34bbb75..48dbd406b 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage.swift
@@ -9,6 +9,12 @@ import Foundation
/// Implements a red-black tree for efficiently editing, storing and retrieving `TextLine`s.
final class TextLineStorage {
+ struct TextLinePosition {
+ let node: Node
+ let offset: Int
+ let height: CGFloat
+ }
+
#if DEBUG
var root: Node?
#else
@@ -54,7 +60,7 @@ final class TextLineStorage {
if currentOffset >= index {
if node.left != nil {
currentNode = node.left
- currentOffset -= (node.left?.leftSubtreeOffset ?? 0) + (node.left?.length ?? 0)
+ currentOffset = (currentOffset - node.leftSubtreeOffset) + (node.left?.leftSubtreeOffset ?? 0)
} else {
node.left = insertedNode
insertedNode.parent = node
@@ -80,11 +86,38 @@ final class TextLineStorage {
/// Complexity: `O(log n)`
/// - Parameter index: The index to fetch for.
/// - Returns: A text line object representing a generated line object and the offset in the document of the line.
- /// If the line was not found, the offset will be `-1`.
-// public func getLine(atIndex index: Int) -> (TextLine?, Int) {
-// let result = search(for: index)
-// return (result.0?.line, result.1)
-// }
+ public func getLine(atIndex index: Int) -> TextLinePosition? {
+ return search(for: index)
+ }
+
+ /// Fetches a line for the given `y` value.
+ ///
+ /// Complexity: `O(log n)`
+ /// - Parameter position: The position to fetch for.
+ /// - Returns: A text line object representing a generated line object and the offset in the document of the line.
+ public func getLine(atPosition posY: CGFloat) -> TextLinePosition? {
+ var currentNode = root
+ var currentOffset: Int = root?.leftSubtreeOffset ?? 0
+ var currentHeight: CGFloat = root?.leftSubtreeHeight ?? 0
+ while let node = currentNode {
+ // If index is in the range [currentOffset..= currentHeight && posY < currentHeight + node.height {
+ return TextLinePosition(node: node, offset: currentOffset, height: currentHeight)
+ } else if currentHeight > posY {
+ currentNode = node.left
+ currentOffset = (currentOffset - node.leftSubtreeOffset) + (node.left?.leftSubtreeOffset ?? 0)
+ currentHeight = (currentHeight - node.leftSubtreeHeight) + (node.left?.leftSubtreeHeight ?? 0)
+ } else if node.leftSubtreeHeight < posY {
+ currentNode = node.right
+ currentOffset += node.length + (node.right?.leftSubtreeOffset ?? 0)
+ currentHeight += node.height + (node.right?.leftSubtreeHeight ?? 0)
+ } else {
+ currentNode = nil
+ }
+ }
+
+ return nil
+ }
/// Applies a length change at the given index.
///
@@ -103,19 +136,21 @@ final class TextLineStorage {
/// - deltaHeight: The change in height of the document.
public func update(atIndex index: Int, delta: Int, deltaHeight: CGFloat) {
assert(index >= 0 && index < self.length, "Invalid index, expected between 0 and \(self.length). Got \(index)")
- assert(delta != 0, "Delta must be non-0")
- let (node, offset) = search(for: index)
- guard let node, offset > -1 else { return }
+ assert(delta != 0 || deltaHeight != 0, "Delta must be non-0")
+ guard let position = search(for: index) else {
+ assertionFailure("No line found at index \(index)")
+ return
+ }
if delta < 0 {
assert(
- index - offset > delta,
+ index - position.offset > delta,
"Delta too large. Deleting \(-delta) from line at position \(index) extends beyond the line's range."
)
}
-
- node.length += delta
- node.height += deltaHeight
- metaFixup(startingAt: node, delta: delta, deltaHeight: deltaHeight)
+ length += delta
+ position.node.length += delta
+ position.node.height += deltaHeight
+ metaFixup(startingAt: position.node, delta: delta, deltaHeight: deltaHeight)
}
/// Deletes a line at the given index.
@@ -135,12 +170,79 @@ final class TextLineStorage {
print(
treeString(root!) { node in
(
- "\(node.length)[\(node.leftSubtreeOffset)\(node.color == .red ? "R" : "B")]",
+ "\(node.length)[\(node.leftSubtreeOffset)\(node.color == .red ? "R" : "B")][\(node.height), \(node.leftSubtreeHeight)]",
node.left,
node.right
)
}
)
+ print("")
+ }
+
+ /// Efficiently builds the tree from the given array of lines.
+ /// - Parameter lines: The lines to use to build the tree.
+ public func build(from lines: [(TextLine, Int)], estimatedLineHeight: CGFloat) {
+ root = build(lines: lines, estimatedLineHeight: estimatedLineHeight, left: 0, right: lines.count, parent: nil).0
+ count = lines.count
+ }
+
+ /// Recursively builds a subtree given an array of sorted lines, and a left and right indexes.
+ /// - Parameters:
+ /// - lines: The lines to use to build the subtree.
+ /// - estimatedLineHeight: An estimated line height to add to the allocated nodes.
+ /// - left: The left index to use.
+ /// - right: The right index to use.
+ /// - parent: The parent of the subtree, `nil` if this is the root.
+ /// - Returns: A node, if available, along with it's subtree's height and offset.
+ private func build(
+ lines: [(TextLine, Int)],
+ estimatedLineHeight: CGFloat,
+ left: Int,
+ right: Int,
+ parent: Node?
+ ) -> (Node?, Int?, CGFloat?) { // swiftlint:disable:this large_tuple
+ guard left < right else { return (nil, nil, nil) }
+ let mid = left + (right - left)/2
+ let node = Node(
+ length: lines[mid].1,
+ line: lines[mid].0,
+ leftSubtreeOffset: 0,
+ leftSubtreeHeight: 0,
+ height: estimatedLineHeight,
+ color: .black
+ )
+ node.parent = parent
+
+ let (left, leftOffset, leftHeight) = build(
+ lines: lines,
+ estimatedLineHeight: estimatedLineHeight,
+ left: left,
+ right: mid,
+ parent: node
+ )
+ let (right, rightOffset, rightHeight) = build(
+ lines: lines,
+ estimatedLineHeight: estimatedLineHeight,
+ left: mid + 1,
+ right: right,
+ parent: node
+ )
+ node.left = left
+ node.right = right
+
+ if node.left == nil && node.right == nil {
+ node.color = .red
+ }
+
+ length += node.length
+ node.leftSubtreeOffset = leftOffset ?? 0
+ node.leftSubtreeHeight = leftHeight ?? 0
+
+ return (
+ node,
+ node.length + (leftOffset ?? 0) + (rightOffset ?? 0),
+ node.height + (leftHeight ?? 0) + (rightHeight ?? 0)
+ )
}
}
@@ -150,26 +252,28 @@ private extension TextLineStorage {
/// Searches for the given index. Returns a node and offset if found.
/// - Parameter index: The index to look for in the document.
/// - Returns: A tuple containing a node if it was found, and the offset of the node in the document.
- /// The index will be negative if the node was not found.
- func search(for index: Int) -> (Node?, Int) {
+ func search(for index: Int) -> TextLinePosition? { // swiftlint:disable:this large_tuple
var currentNode = root
var currentOffset: Int = root?.leftSubtreeOffset ?? 0
+ var currentHeight: CGFloat = root?.leftSubtreeHeight ?? 0
while let node = currentNode {
// If index is in the range [currentOffset..= currentOffset && index < currentOffset + node.length {
- return (node, currentOffset)
+ return TextLinePosition(node: node, offset: currentOffset, height: currentHeight)
} else if currentOffset > index {
currentNode = node.left
- currentOffset -= (node.left?.leftSubtreeOffset ?? 0) + (node.left?.length ?? 0)
+ currentOffset = (currentOffset - node.leftSubtreeOffset) + (node.left?.leftSubtreeOffset ?? 0)
+ currentHeight = (currentHeight - node.leftSubtreeHeight) + (node.left?.leftSubtreeHeight ?? 0)
} else if node.leftSubtreeOffset < index {
currentNode = node.right
currentOffset += node.length + (node.right?.leftSubtreeOffset ?? 0)
+ currentHeight += node.height + (node.right?.leftSubtreeHeight ?? 0)
} else {
currentNode = nil
}
}
- return (nil, -1)
+ return nil
}
// MARK: - Fixup
@@ -230,31 +334,13 @@ private extension TextLineStorage {
/// Walk up the tree, updating any `leftSubtree` metadata.
func metaFixup(startingAt node: Node, delta: Int, deltaHeight: CGFloat) {
guard node.parent != nil, delta > 0 else { return }
- // Find the first node that needs to be updated (first left child)
- var nodeX: Node? = node
- var nodeXParent: Node? = node.parent
- while nodeX != nil {
- if nodeXParent?.right == nodeX {
- nodeX = nodeXParent
- nodeXParent = nodeX?.parent
- } else {
- nodeX = nil
- }
- }
-
- guard nodeX != nil else { return }
- while nodeX != root {
- if nodeXParent?.left == nodeX {
- nodeXParent?.leftSubtreeOffset += delta
- nodeXParent?.leftSubtreeHeight += deltaHeight
- }
- if nodeXParent != nil {
- count += 1
- nodeX = nodeXParent
- nodeXParent = nodeX?.parent
- } else {
- return
+ var node: Node? = node
+ while node != nil, node != root {
+ if isLeftChild(node!) {
+ node?.parent?.leftSubtreeOffset += delta
+ node?.parent?.leftSubtreeHeight += deltaHeight
}
+ node = node?.parent
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/Typesetter.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/Typesetter.swift
index 41eaebd39..ee4387366 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/Typesetter.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/Typesetter.swift
@@ -17,18 +17,19 @@ final class Typesetter {
init() { }
- func prepareToTypeset(_ string: NSAttributedString) {
+ func prepareToTypeset(_ string: NSAttributedString, maxWidth: CGFloat) {
self.typesetter = CTTypesetterCreateWithAttributedString(string)
self.string = string
+ generateLines(maxWidth: maxWidth)
}
// MARK: - Generate lines
- func generateLines(maxWidth: CGFloat) {
+ private func generateLines(maxWidth: CGFloat) {
guard let typesetter else { return }
var startIndex = 0
- var lineBreak = suggestLineBreak(using: typesetter, startingOffset: startIndex, constrainingWidth: maxWidth)
- while lineBreak < string.length - 1 {
+ while startIndex < string.length {
+ let lineBreak = suggestLineBreak(using: typesetter, startingOffset: startIndex, constrainingWidth: maxWidth)
lineFragments.append(typesetLine(range: NSRange(location: startIndex, length: lineBreak)))
startIndex = lineBreak
}
diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift
index 3309b1e21..326481408 100644
--- a/Sources/CodeEditTextView/TextView/TextView.swift
+++ b/Sources/CodeEditTextView/TextView/TextView.swift
@@ -47,7 +47,10 @@ class TextView: NSView {
init(string: String) {
self.storage = NSTextStorage(string: string)
self.storageDelegate = MultiStorageDelegate()
- self.layoutManager = TextLayoutManager(textStorage: storage)
+ self.layoutManager = TextLayoutManager(
+ textStorage: storage,
+ typingAttributes: [.font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)]
+ )
storage.delegate = storageDelegate
storageDelegate.addDelegate(layoutManager)
@@ -59,6 +62,14 @@ class TextView: NSView {
postsBoundsChangedNotifications = true
autoresizingMask = [.width, .height]
+
+ frame = NSRect(
+ x: 0,
+ y: 0,
+ width: enclosingScrollView?.documentVisibleRect.width ?? 1000,
+ height: layoutManager.estimatedHeight()
+ )
+ print(frame)
}
required init?(coder: NSCoder) {
@@ -68,7 +79,13 @@ class TextView: NSView {
override func viewWillMove(toWindow newWindow: NSWindow?) {
super.viewWillMove(toWindow: newWindow)
guard newWindow != nil else { return }
- layoutManager.prepareForDisplay()
+ // Do some layout prep
+ frame = NSRect(
+ x: 0,
+ y: 0,
+ width: enclosingScrollView?.documentVisibleRect.width ?? 1000,
+ height: layoutManager.estimatedHeight()
+ )
}
// MARK: - Draw
@@ -80,18 +97,18 @@ class TextView: NSView {
override func makeBackingLayer() -> CALayer {
let layer = CETiledLayer()
layer.tileSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: 1000)
+ layer.levelsOfDetail = 4
+ layer.levelsOfDetailBias = 2
layer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
return layer
}
override func draw(_ dirtyRect: NSRect) {
guard let ctx = NSGraphicsContext.current?.cgContext else { return }
- ctx.saveGState()
- ctx.setStrokeColor(NSColor.red.cgColor)
- ctx.setFillColor(NSColor.orange.cgColor)
- ctx.setLineWidth(10)
- ctx.addEllipse(in: dirtyRect)
- ctx.drawPath(using: .fillStroke)
- ctx.restoreGState()
+ layoutManager.draw(inRect: dirtyRect, context: ctx)
+ }
+
+ private func updateHeightIfNeeded() {
+
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextViewController.swift b/Sources/CodeEditTextView/TextView/TextViewController.swift
index 3871cefee..600984d51 100644
--- a/Sources/CodeEditTextView/TextView/TextViewController.swift
+++ b/Sources/CodeEditTextView/TextView/TextViewController.swift
@@ -25,11 +25,14 @@ public class TextViewController: NSViewController {
public override func loadView() {
scrollView = NSScrollView()
textView = TextView(string: string)
- textView.frame.size = CGSize(width: 500, height: 100000)
+// textView.translatesAutoresizingMaskIntoConstraints = false
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.hasVerticalScroller = true
+ scrollView.hasHorizontalRuler = true
scrollView.documentView = textView
+ scrollView.automaticallyAdjustsContentInsets = false
+ scrollView.contentInsets = NSEdgeInsets(top: 128, left: 0, bottom: 32, right: 0)
self.view = scrollView
diff --git a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
index 5959e937a..7f0a0f688 100644
--- a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
+++ b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
@@ -3,61 +3,61 @@ import XCTest
final class TextLayoutLineStorageTests: XCTestCase {
func test_insert() {
- let tree = TextLayoutLineStorage()
-// tree.insert(atIndex: 0, length: 1)
-// tree.insert(atIndex: 1, length: 2)
-// tree.insert(atIndex: 1, length: 3)
-// tree.printTree()
-// tree.insert(atIndex: 6, length: 4)
-// tree.printTree()
-// tree.insert(atIndex: 4, length: 5)
-// tree.printTree()
-// tree.insert(atIndex: 1, length: 6)
-// tree.printTree()
-// tree.insert(atIndex: 0, length: 7)
-// tree.printTree()
-// tree.insert(atIndex: 28, length: 8)
-// tree.printTree()
-// tree.insert(atIndex: 36, length: 9)
-// tree.printTree()
-// tree.insert(atIndex: 45, length: 10)
-// tree.printTree()
-// tree.insert(atIndex: 55, length: 11)
-// tree.printTree()
-// tree.insert(atIndex: 66, length: 12)
- tree.printTree()
-
- tree.update(atIndex: 18, delta: 2)
- tree.printTree()
+ let tree = TextLineStorage()
+ let stringRef = NSString()
+ var sum = 0
+ for i in 0..<20 {
+ tree.insert(
+ line: .init(stringRef: stringRef, range: .init(location: 0, length: 0)),
+ atIndex: sum,
+ length: i + 1,
+ height: 1.0
+ )
+ sum += i + 1
+ }
+ XCTAssert(tree.getLine(atIndex: 2)?.0.length == 2, "Found line incorrect, expected length of 2.")
+ XCTAssert(tree.getLine(atIndex: 36)?.0.length == 9, "Found line incorrect, expected length of 9.")
+ }
- tree.update(atIndex: 28, delta: -2)
- tree.printTree()
+ func test_update() {
+ let tree = TextLineStorage()
+ let stringRef = NSString()
+ var sum = 0
+ for i in 0..<20 {
+ tree.insert(
+ line: .init(stringRef: stringRef, range: .init(location: 0, length: 0)),
+ atIndex: sum,
+ length: i + 1,
+ height: 1.0
+ )
+ sum += i + 1
+ }
-// print(tree.search(for: 7)?.length)
-// print(tree.search(for: 17)?.length)
-// print(tree.search(for: 0)?.length)
-// var n = tree.root?.minimum()
-// while let node = n {
-// print("\(node.length)", terminator: "")
-// n = node.getSuccessor()
-// }
- print("")
}
- func test_insertInc() {
- let tree = TextLayoutLineStorage()
- for i in 0..<100_000 {
- tree.insert(line: .init(), atIndex: i, length: 1)
+ func test_insertPerformance() {
+ let tree = TextLineStorage()
+ let stringRef = NSString()
+ measure {
+ for i in 0..<250_000 {
+ tree.insert(line: .init(stringRef: stringRef, range: .init(location: 0, length: 0)), atIndex: i, length: 1, height: 0.0)
+ }
}
}
- func test_insertPerformance() {
- let tree = TextLayoutLineStorage()
+ func test_insertFastPerformance() {
+ let tree = TextLineStorage()
+ let stringRef = NSString()
measure {
+ var lines: [(TextLine, Int)] = []
for i in 0..<250_000 {
- tree.insert(line: .init(), atIndex: i, length: 1)
+ lines.append((
+ TextLine(stringRef: stringRef, range: NSRange(location: i, length: 1)),
+ i + 1
+ ))
}
+ tree.build(from: lines, estimatedLineHeight: 1.0)
}
}
}
From ab76fef9420074fe3cd472dcfa31999423104cb1 Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Sun, 16 Jul 2023 20:44:15 -0500
Subject: [PATCH 04/75] Wrap lines, remove custom layer
Removing CETiledLayer due to the fact that the layer refuses to draw fast, resulting in a laggy view when resizing that takes seconds to redraw and leaves artifacts behind while resizing.
---
.../CodeEditTextView/CodeEditTextView.swift | 13 +-
.../TextView/CETiledLayer.swift | 50 ------
.../TextLayoutManager/TextLayoutManager.swift | 113 +++++++++---
.../TextLineStorage+Iterator.swift | 40 +++++
.../Typesetter/LineFragment.swift | 2 +-
.../Typesetter/Typesetter.swift | 2 +-
.../CodeEditTextView/TextView/TextView.swift | 168 ++++++++++++++----
.../TextView/TextViewController.swift | 63 ++++++-
8 files changed, 327 insertions(+), 124 deletions(-)
delete mode 100644 Sources/CodeEditTextView/TextView/CETiledLayer.swift
create mode 100644 Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Iterator.swift
diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift
index 079f187a9..5d9c80b40 100644
--- a/Sources/CodeEditTextView/CodeEditTextView.swift
+++ b/Sources/CodeEditTextView/CodeEditTextView.swift
@@ -110,7 +110,18 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
// bracketPairHighlight: bracketPairHighlight
// )
// return controller
- return TextViewController(string: text)
+ return TextViewController(
+ string: text,
+ font: font,
+ theme: theme,
+ lineHeight: lineHeight,
+ wrapLines: wrapLines,
+ editorOverscroll: editorOverscroll,
+ useThemeBackground: useThemeBackground,
+ contentInsets: contentInsets,
+ isEditable: isEditable,
+ letterSpacing: letterSpacing
+ )
}
public func updateNSViewController(_ controller: TextViewController, context: Context) {
diff --git a/Sources/CodeEditTextView/TextView/CETiledLayer.swift b/Sources/CodeEditTextView/TextView/CETiledLayer.swift
deleted file mode 100644
index 941c8d344..000000000
--- a/Sources/CodeEditTextView/TextView/CETiledLayer.swift
+++ /dev/null
@@ -1,50 +0,0 @@
-//
-// CETiledLayer.swift
-//
-//
-// Created by Khan Winter on 6/27/23.
-//
-
-import Cocoa
-
-class CETiledLayer: CATiledLayer {
- open override class func fadeDuration() -> CFTimeInterval {
- 0
- }
-
- override public class func defaultAction(forKey event: String) -> CAAction? {
- return NSNull()
- }
-
- /// A dictionary containing layer actions.
- /// Disable animations
- override public var actions: [String: CAAction]? {
- get {
- super.actions
- }
- set {
- return
- }
- }
-
- public override init() {
- super.init()
- needsDisplayOnBoundsChange = true
- }
-
- public init(frame frameRect: CGRect) {
- super.init()
- needsDisplayOnBoundsChange = true
- frame = frameRect
- }
-
- public required init?(coder: NSCoder) {
- super.init(coder: coder)
- needsDisplayOnBoundsChange = true
- }
-
- public override init(layer: Any) {
- super.init(layer: layer)
- needsDisplayOnBoundsChange = true
- }
-}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
index 7128250a0..6121c9453 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -8,15 +8,29 @@
import Foundation
import AppKit
-protocol TextLayoutManagerDelegate: AnyObject { }
+protocol TextLayoutManagerDelegate: AnyObject {
+ func maxWidthDidChange(newWidth: CGFloat)
+ func textViewportSize() -> CGSize
+}
class TextLayoutManager: NSObject {
+ // MARK: - Public Config
+
+ public weak var delegate: TextLayoutManagerDelegate?
+ public var typingAttributes: [NSAttributedString.Key: Any]
+ public var lineHeightMultiplier: CGFloat
+ public var wrapLines: Bool
+
+ // MARK: - Internal
+
private unowned var textStorage: NSTextStorage
private var lineStorage: TextLineStorage
- public var typingAttributes: [NSAttributedString.Key: Any] = [
- .font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular),
- .paragraphStyle: NSParagraphStyle.default.copy()
- ]
+
+ private var maxLineWidth: CGFloat = 0 {
+ didSet {
+ delegate?.maxWidthDidChange(newWidth: maxLineWidth)
+ }
+ }
// MARK: - Init
@@ -24,10 +38,17 @@ class TextLayoutManager: NSObject {
/// - Parameters:
/// - textStorage: The text storage object to use as a data source.
/// - typingAttributes: The attributes to use while typing.
- init(textStorage: NSTextStorage, typingAttributes: [NSAttributedString.Key: Any]) {
+ init(
+ textStorage: NSTextStorage,
+ typingAttributes: [NSAttributedString.Key: Any],
+ lineHeightMultiplier: CGFloat,
+ wrapLines: Bool
+ ) {
self.textStorage = textStorage
self.lineStorage = TextLineStorage()
self.typingAttributes = typingAttributes
+ self.lineHeightMultiplier = lineHeightMultiplier
+ self.wrapLines = wrapLines
super.init()
textStorage.addAttributes(typingAttributes, range: NSRange(location: 0, length: textStorage.length))
prepareTextLines()
@@ -37,9 +58,11 @@ class TextLayoutManager: NSObject {
/// Parses the text storage object into lines and builds the `lineStorage` object from those lines.
private func prepareTextLines() {
guard lineStorage.count == 0 else { return }
+#if DEBUG
var info = mach_timebase_info()
guard mach_timebase_info(&info) == KERN_SUCCESS else { return }
let start = mach_absolute_time()
+#endif
func getNextLine(startingAt location: Int) -> NSRange? {
let range = NSRange(location: location, length: 0)
@@ -53,10 +76,6 @@ class TextLayoutManager: NSObject {
}
}
- let estimatedLineHeight = NSAttributedString(string: " ", attributes: typingAttributes).boundingRect(
- with: NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
- ).height
-
var index = 0
var lines: [(TextLine, Int)] = []
while let range = getNextLine(startingAt: index) {
@@ -66,7 +85,7 @@ class TextLayoutManager: NSObject {
))
index = NSMaxRange(range)
}
- // Get the last line
+ // Create the last line
if textStorage.length - index > 0 {
lines.append((
TextLine(stringRef: textStorage, range: NSRange(location: index, length: textStorage.length - index)),
@@ -74,45 +93,74 @@ class TextLayoutManager: NSObject {
))
}
- lineStorage.build(from: lines, estimatedLineHeight: estimatedLineHeight)
+ // Use a more efficient tree building algorithm than adding lines as calculated in the above loop.
+ lineStorage.build(from: lines, estimatedLineHeight: estimateLineHeight())
+#if DEBUG
let end = mach_absolute_time()
let elapsed = end - start
let nanos = elapsed * UInt64(info.numer) / UInt64(info.denom)
print("Layout Manager built in: ", TimeInterval(nanos) / TimeInterval(NSEC_PER_MSEC), "ms")
+#endif
}
- // MARK: - API
+ private func estimateLineHeight() -> CGFloat {
+ let string = NSAttributedString(string: "0", attributes: typingAttributes)
+ let typesetter = CTTypesetterCreateWithAttributedString(string)
+ let ctLine = CTTypesetterCreateLine(typesetter, CFRangeMake(0, 1))
+ var ascent: CGFloat = 0
+ var descent: CGFloat = 0
+ var leading: CGFloat = 0
+ CTLineGetTypographicBounds(ctLine, &ascent, &descent, &leading)
+ return (ascent + descent + leading) * lineHeightMultiplier
+ }
- func estimatedHeight() -> CGFloat {
+ // MARK: - Public Convenience Methods
+
+ public func estimatedHeight() -> CGFloat {
guard let position = lineStorage.getLine(atIndex: lineStorage.length - 1) else {
return 0.0
}
return position.node.height + position.height
}
- func estimatedWidth() -> CGFloat { 0 }
- func textLineForPosition(_ posY: CGFloat) -> TextLine? {
+ public func estimatedWidth() -> CGFloat {
+ maxLineWidth
+ }
+
+ public func textLineForPosition(_ posY: CGFloat) -> TextLine? {
lineStorage.getLine(atPosition: posY)?.node.line
}
// MARK: - Rendering
- func draw(inRect rect: CGRect, context: CGContext) {
+ public func invalidateLayoutForRect(_ rect: NSRect) {
+ // Get all lines in rect and discard their line fragment data
+ for position in lineStorage.linesStartingAt(rect.minY, until: rect.maxY) {
+ position.node.line.typesetter.lineFragments.removeAll(keepingCapacity: true)
+ }
+ }
+
+ internal func draw(inRect rect: CGRect, context: CGContext) {
// Get all lines in rect & draw!
- var currentPosition = lineStorage.getLine(atPosition: rect.minY)
- while let position = currentPosition, position.height < rect.maxY {
- let lineHeight = drawLine(
+ for position in lineStorage.linesStartingAt(rect.minY, until: rect.maxY) {
+ let lineSize = drawLine(
line: position.node.line,
offsetHeight: position.height,
minY: rect.minY,
maxY: rect.maxY,
context: context
)
- if lineHeight != position.node.height {
- lineStorage.update(atIndex: position.offset, delta: 0, deltaHeight: lineHeight - position.node.height)
+ if lineSize.height != position.node.height {
+ lineStorage.update(
+ atIndex: position.offset,
+ delta: 0,
+ deltaHeight: lineSize.height - position.node.height
+ )
+ }
+ if maxLineWidth < lineSize.width {
+ maxLineWidth = lineSize.width
}
- currentPosition = lineStorage.getLine(atIndex: position.offset + position.node.length)
}
}
@@ -122,19 +170,25 @@ class TextLayoutManager: NSObject {
/// - offsetHeight: The initial offset of the line.
/// - minY: The minimum Y position to begin drawing from.
/// - maxY: The maximum Y position to draw to.
+ /// - Returns: The size of the rendered line.
private func drawLine(
line: TextLine,
offsetHeight: CGFloat,
minY: CGFloat,
maxY: CGFloat,
context: CGContext
- ) -> CGFloat {
+ ) -> CGSize {
if line.typesetter.lineFragments.isEmpty {
- line.prepareForDisplay(maxWidth: .greatestFiniteMagnitude)
+ line.prepareForDisplay(
+ maxWidth: wrapLines
+ ? delegate?.textViewportSize().width ?? .greatestFiniteMagnitude
+ : .greatestFiniteMagnitude
+ )
}
var height = offsetHeight
+ var maxWidth: CGFloat = 0
for lineFragment in line.typesetter.lineFragments {
- if height + lineFragment.height >= minY {
+ if height + (lineFragment.height * lineHeightMultiplier) >= minY {
// The fragment is within the valid region
context.saveGState()
context.textMatrix = .init(scaleX: 1, y: -1)
@@ -143,9 +197,10 @@ class TextLayoutManager: NSObject {
CTLineDraw(lineFragment.ctLine, context)
context.restoreGState()
}
- height += lineFragment.height
+ maxWidth = max(lineFragment.width, maxWidth)
+ height += lineFragment.height * lineHeightMultiplier
}
- return height - offsetHeight
+ return CGSize(width: maxWidth, height: height - offsetHeight)
}
}
@@ -156,6 +211,6 @@ extension TextLayoutManager: NSTextStorageDelegate {
range editedRange: NSRange,
changeInLength delta: Int
) {
-
+
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Iterator.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Iterator.swift
new file mode 100644
index 000000000..9095ebba8
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Iterator.swift
@@ -0,0 +1,40 @@
+//
+// File.swift
+//
+//
+// Created by Khan Winter on 7/16/23.
+//
+
+import Foundation
+
+extension TextLineStorage {
+ func linesStartingAt(_ minY: CGFloat, until maxY: CGFloat) -> TextLineStorageYIterator {
+ return TextLineStorageYIterator(storage: self, minY: minY, maxY: maxY)
+ }
+
+ struct TextLineStorageYIterator: Sequence, IteratorProtocol {
+ let storage: TextLineStorage
+ let minY: CGFloat
+ let maxY: CGFloat
+ var currentPosition: TextLinePosition?
+
+ mutating func next() -> TextLinePosition? {
+ if let currentPosition {
+ guard currentPosition.height + currentPosition.node.height < maxY,
+ let nextNode = currentPosition.node.getSuccessor() else { return nil }
+ self.currentPosition = TextLinePosition(
+ node: nextNode,
+ offset: currentPosition.offset + currentPosition.node.length,
+ height: currentPosition.height + currentPosition.node.height
+ )
+ return self.currentPosition!
+ } else if let nextPosition = storage.getLine(atPosition: minY) {
+ self.currentPosition = nextPosition
+ return nextPosition
+ } else {
+ return nil
+ }
+ }
+ }
+
+}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/LineFragment.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/LineFragment.swift
index 8e37a90f2..ecb788d28 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/LineFragment.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/LineFragment.swift
@@ -7,7 +7,7 @@
import AppKit
-final class LineFragment {
+struct LineFragment {
var ctLine: CTLine
var width: CGFloat
var height: CGFloat
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/Typesetter.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/Typesetter.swift
index ee4387366..054a08644 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/Typesetter.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/Typesetter.swift
@@ -30,7 +30,7 @@ final class Typesetter {
var startIndex = 0
while startIndex < string.length {
let lineBreak = suggestLineBreak(using: typesetter, startingOffset: startIndex, constrainingWidth: maxWidth)
- lineFragments.append(typesetLine(range: NSRange(location: startIndex, length: lineBreak)))
+ lineFragments.append(typesetLine(range: NSRange(location: startIndex, length: lineBreak - startIndex)))
startIndex = lineBreak
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift
index 326481408..26fd569fe 100644
--- a/Sources/CodeEditTextView/TextView/TextView.swift
+++ b/Sources/CodeEditTextView/TextView/TextView.swift
@@ -12,7 +12,7 @@ import STTextView
```
TextView
- |-> LayoutManager Creates and manages TextLines from the text storage
+ |-> LayoutManager Creates, manages, and renders TextLines from the text storage
| |-> [TextLine] Represents a text line
| | |-> Typesetter Lays out and calculates line fragments
| | | |-> [LineFragment] Represents a visual text line (may be multiple if text wrapping is on)
@@ -21,55 +21,99 @@ import STTextView
```
*/
class TextView: NSView {
- override var visibleRect: NSRect {
- if let scrollView = enclosingScrollView {
- // +200px vertically for a bit of padding
- return scrollView.visibleRect.insetBy(dx: 0, dy: 400)
- } else {
- return super.visibleRect
- }
- }
-
// MARK: - Configuration
func setString(_ string: String) {
storage.setAttributedString(.init(string: string))
}
- // MARK: - Objects
+ public var font: NSFont
+ public var theme: EditorTheme
+ public var lineHeight: CGFloat
+ public var wrapLines: Bool
+ public var editorOverscroll: CGFloat
+ public var useThemeBackground: Bool
+ public var contentInsets: NSEdgeInsets?
+ public var isEditable: Bool
+ public var letterSpacing: Double
+
+ // MARK: - Internal Properties
private var storage: NSTextStorage!
private var storageDelegate: MultiStorageDelegate!
private var layoutManager: TextLayoutManager!
+ var scrollView: NSScrollView? {
+ guard let enclosingScrollView, enclosingScrollView.documentView == self else { return nil }
+ return enclosingScrollView
+ }
+
+// override open var frame: NSRect {
+// get { super.frame }
+// set {
+// super.frame = newValue
+// print(#function)
+// layoutManager.invalidateLayoutForRect(newValue)
+// }
+// }
+
// MARK: - Init
- init(string: String) {
+ init(
+ string: String,
+ font: NSFont,
+ theme: EditorTheme,
+ lineHeight: CGFloat,
+ wrapLines: Bool,
+ editorOverscroll: CGFloat,
+ useThemeBackground: Bool,
+ contentInsets: NSEdgeInsets?,
+ isEditable: Bool,
+ letterSpacing: Double
+ ) {
self.storage = NSTextStorage(string: string)
self.storageDelegate = MultiStorageDelegate()
self.layoutManager = TextLayoutManager(
textStorage: storage,
- typingAttributes: [.font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)]
+ typingAttributes: [
+ .font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular),
+ .paragraphStyle: {
+ // swiftlint:disable:next force_cast
+ let paragraph = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle
+// paragraph.tabStops.removeAll()
+// paragraph.defaultTabInterval = CGFloat(tabWidth) * fontCharWidth
+ return paragraph
+ }()
+ ],
+ lineHeightMultiplier: lineHeight,
+ wrapLines: wrapLines
)
+ self.font = font
+ self.theme = theme
+ self.lineHeight = lineHeight
+ self.wrapLines = wrapLines
+ self.editorOverscroll = editorOverscroll
+ self.useThemeBackground = useThemeBackground
+ self.contentInsets = contentInsets
+ self.isEditable = isEditable
+ self.letterSpacing = letterSpacing
+
storage.delegate = storageDelegate
storageDelegate.addDelegate(layoutManager)
// TODO: Add Highlighter as storage delegate #2
super.init(frame: .zero)
+
+ layoutManager.delegate = self
+
wantsLayer = true
postsFrameChangedNotifications = true
postsBoundsChangedNotifications = true
autoresizingMask = [.width, .height]
- frame = NSRect(
- x: 0,
- y: 0,
- width: enclosingScrollView?.documentVisibleRect.width ?? 1000,
- height: layoutManager.estimatedHeight()
- )
- print(frame)
+ updateFrameIfNeeded()
}
required init?(coder: NSCoder) {
@@ -78,14 +122,12 @@ class TextView: NSView {
override func viewWillMove(toWindow newWindow: NSWindow?) {
super.viewWillMove(toWindow: newWindow)
- guard newWindow != nil else { return }
- // Do some layout prep
- frame = NSRect(
- x: 0,
- y: 0,
- width: enclosingScrollView?.documentVisibleRect.width ?? 1000,
- height: layoutManager.estimatedHeight()
- )
+ updateFrameIfNeeded()
+ }
+
+ override func viewDidEndLiveResize() {
+ super.viewDidEndLiveResize()
+ updateFrameIfNeeded()
}
// MARK: - Draw
@@ -94,13 +136,24 @@ class TextView: NSView {
true
}
- override func makeBackingLayer() -> CALayer {
- let layer = CETiledLayer()
- layer.tileSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: 1000)
- layer.levelsOfDetail = 4
- layer.levelsOfDetailBias = 2
- layer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
- return layer
+// override func makeBackingLayer() -> CALayer {
+// let layer = CETiledLayer()
+// layer.tileSize = CGSize(width: 2000, height: 1000)
+//// layer.levelsOfDetail = 4
+//// layer.levelsOfDetailBias = 2
+// layer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
+// layer.contentsScale = NSScreen.main?.backingScaleFactor ?? 1.0
+// layer.drawsAsynchronously = false
+// return layer
+// }
+
+ override var visibleRect: NSRect {
+ if let scrollView = scrollView {
+ // +200px vertically for a bit of padding
+ return scrollView.visibleRect.insetBy(dx: 0, dy: 400)
+ } else {
+ return super.visibleRect
+ }
}
override func draw(_ dirtyRect: NSRect) {
@@ -108,7 +161,50 @@ class TextView: NSView {
layoutManager.draw(inRect: dirtyRect, context: ctx)
}
- private func updateHeightIfNeeded() {
+ public func updateFrameIfNeeded() {
+ var availableSize = scrollView?.contentSize ?? .zero
+ availableSize.height -= (scrollView?.contentInsets.top ?? 0) + (scrollView?.contentInsets.bottom ?? 0)
+ let newHeight = layoutManager.estimatedHeight()
+ let newWidth = layoutManager.estimatedWidth()
+
+ var didUpdate = false
+
+ if frame.size.height != availableSize.height
+ || (newHeight > availableSize.height && frame.size.height != newHeight) {
+ frame.size.height = max(availableSize.height, newHeight + editorOverscroll)
+ didUpdate = true
+ }
+
+ if wrapLines && frame.size.width != availableSize.width {
+ frame.size.width = availableSize.width
+ didUpdate = true
+ } else if !wrapLines && newWidth > availableSize.width && frame.size.width != newWidth {
+ frame.size.width = max(newWidth, availableSize.width)
+ didUpdate = true
+ }
+
+ if didUpdate {
+ needsLayout = true
+ needsDisplay = true
+ layoutManager.invalidateLayoutForRect(frame)
+ }
+ }
+}
+
+// MARK: - TextLayoutManagerDelegate
+
+extension TextView: TextLayoutManagerDelegate {
+ func maxWidthDidChange(newWidth: CGFloat) {
+ updateFrameIfNeeded()
+ }
+ func textViewportSize() -> CGSize {
+ if let scrollView = scrollView {
+ var size = scrollView.contentSize
+ size.height -= scrollView.contentInsets.top + scrollView.contentInsets.bottom
+ return size
+ } else {
+ return CGSize(width: CGFloat.infinity, height: CGFloat.infinity)
+ }
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextViewController.swift b/Sources/CodeEditTextView/TextView/TextViewController.swift
index 600984d51..9180e69eb 100644
--- a/Sources/CodeEditTextView/TextView/TextViewController.swift
+++ b/Sources/CodeEditTextView/TextView/TextViewController.swift
@@ -11,10 +11,39 @@ public class TextViewController: NSViewController {
var scrollView: NSScrollView!
var textView: TextView!
- var string: String
+ public var string: String
+ public var font: NSFont
+ public var theme: EditorTheme
+ public var lineHeight: CGFloat
+ public var wrapLines: Bool
+ public var editorOverscroll: CGFloat
+ public var useThemeBackground: Bool
+ public var contentInsets: NSEdgeInsets?
+ public var isEditable: Bool
+ public var letterSpacing: Double
- init(string: String) {
+ init(
+ string: String,
+ font: NSFont,
+ theme: EditorTheme,
+ lineHeight: CGFloat,
+ wrapLines: Bool,
+ editorOverscroll: CGFloat,
+ useThemeBackground: Bool,
+ contentInsets: NSEdgeInsets?,
+ isEditable: Bool,
+ letterSpacing: Double
+ ) {
self.string = string
+ self.font = font
+ self.theme = theme
+ self.lineHeight = lineHeight
+ self.wrapLines = wrapLines
+ self.editorOverscroll = editorOverscroll
+ self.useThemeBackground = useThemeBackground
+ self.contentInsets = contentInsets
+ self.isEditable = isEditable
+ self.letterSpacing = letterSpacing
super.init(nibName: nil, bundle: nil)
}
@@ -24,15 +53,29 @@ public class TextViewController: NSViewController {
public override func loadView() {
scrollView = NSScrollView()
- textView = TextView(string: string)
-// textView.translatesAutoresizingMaskIntoConstraints = false
+ textView = TextView(
+ string: string,
+ font: font,
+ theme: theme,
+ lineHeight: lineHeight,
+ wrapLines: wrapLines,
+ editorOverscroll: editorOverscroll,
+ useThemeBackground: useThemeBackground,
+ contentInsets: contentInsets,
+ isEditable: isEditable,
+ letterSpacing: letterSpacing
+ )
+ textView.translatesAutoresizingMaskIntoConstraints = false
scrollView.translatesAutoresizingMaskIntoConstraints = false
+ scrollView.contentView.postsFrameChangedNotifications = true
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalRuler = true
scrollView.documentView = textView
- scrollView.automaticallyAdjustsContentInsets = false
- scrollView.contentInsets = NSEdgeInsets(top: 128, left: 0, bottom: 32, right: 0)
+ if let contentInsets {
+ scrollView.automaticallyAdjustsContentInsets = false
+ scrollView.contentInsets = contentInsets
+ }
self.view = scrollView
@@ -42,5 +85,13 @@ public class TextViewController: NSViewController {
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
+
+ NotificationCenter.default.addObserver(
+ forName: NSView.frameDidChangeNotification,
+ object: scrollView.contentView,
+ queue: .main
+ ) { _ in
+ self.textView.updateFrameIfNeeded()
+ }
}
}
From 864400c463a7822fed9c79fdf4d862a968263d21 Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Sun, 16 Jul 2023 22:03:24 -0500
Subject: [PATCH 05/75] Begin integrating highlighting system
---
.../CodeEditTextView/CodeEditTextView.swift | 29 ++----
.../STTextViewController+Highlighter.swift | 14 +--
.../Highlighting/Highlighter.swift | 40 +++------
.../Highlighting/HighlighterTextView.swift | 10 +++
.../TextLayoutManager/TextLayoutManager.swift | 18 +++-
.../TextView/TextLayoutManager/TextLine.swift | 6 +-
.../TextLineStorage+Iterator.swift | 29 +++++-
.../CodeEditTextView/TextView/TextView.swift | 68 ++++++--------
.../TextView/TextViewController.swift | 88 +++++++++++++++++--
TODO.md | 32 +++++++
10 files changed, 227 insertions(+), 107 deletions(-)
create mode 100644 TODO.md
diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift
index 5d9c80b40..ceadd4729 100644
--- a/Sources/CodeEditTextView/CodeEditTextView.swift
+++ b/Sources/CodeEditTextView/CodeEditTextView.swift
@@ -91,36 +91,23 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
public typealias NSViewControllerType = TextViewController // STTextViewController
public func makeNSViewController(context: Context) -> TextViewController {
-// let controller = NSViewControllerType(
-// text: $text,
-// language: language,
-// font: font,
-// theme: theme,
-// tabWidth: tabWidth,
-// indentOption: indentOption,
-// lineHeight: lineHeight,
-// wrapLines: wrapLines,
-// cursorPosition: $cursorPosition,
-// editorOverscroll: editorOverscroll,
-// useThemeBackground: useThemeBackground,
-// highlightProvider: highlightProvider,
-// contentInsets: contentInsets,
-// isEditable: isEditable,
-// letterSpacing: letterSpacing,
-// bracketPairHighlight: bracketPairHighlight
-// )
-// return controller
return TextViewController(
- string: text,
+ string: $text,
+ language: language,
font: font,
theme: theme,
+ tabWidth: tabWidth,
+ indentOption: indentOption,
lineHeight: lineHeight,
wrapLines: wrapLines,
+ cursorPosition: $cursorPosition,
editorOverscroll: editorOverscroll,
useThemeBackground: useThemeBackground,
+ highlightProvider: highlightProvider,
contentInsets: contentInsets,
isEditable: isEditable,
- letterSpacing: letterSpacing
+ letterSpacing: letterSpacing,
+ bracketPairHighlight: bracketPairHighlight
)
}
diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+Highlighter.swift b/Sources/CodeEditTextView/Controller/STTextViewController+Highlighter.swift
index e1d113597..88812cdbf 100644
--- a/Sources/CodeEditTextView/Controller/STTextViewController+Highlighter.swift
+++ b/Sources/CodeEditTextView/Controller/STTextViewController+Highlighter.swift
@@ -11,13 +11,13 @@ import SwiftTreeSitter
extension STTextViewController {
/// Configures the `Highlighter` object
internal func setUpHighlighter() {
- self.highlighter = Highlighter(
- textView: textView,
- highlightProvider: highlightProvider,
- theme: theme,
- attributeProvider: self,
- language: language
- )
+// self.highlighter = Highlighter(
+// textView: textView,
+// highlightProvider: highlightProvider,
+// theme: theme,
+// attributeProvider: self,
+// language: language
+// )
}
/// Sets the highlight provider and re-highlights all text. This method should be used sparingly.
diff --git a/Sources/CodeEditTextView/Highlighting/Highlighter.swift b/Sources/CodeEditTextView/Highlighting/Highlighter.swift
index b0d96932b..45edce9fc 100644
--- a/Sources/CodeEditTextView/Highlighting/Highlighter.swift
+++ b/Sources/CodeEditTextView/Highlighting/Highlighter.swift
@@ -32,7 +32,7 @@ class Highlighter: NSObject {
/// The range of the entire document
private var entireTextRange: Range {
- return 0..<(textView.textContentStorage?.textStorage?.length ?? 0)
+ return 0..<(textView.textStorage.length)
}
/// The set of visible indexes in tht text view
@@ -43,7 +43,7 @@ class Highlighter: NSObject {
// MARK: - UI
/// The text view to highlight
- private var textView: STTextView
+ private var textView: TextView
/// The editor theme
private var theme: EditorTheme
@@ -68,7 +68,7 @@ class Highlighter: NSObject {
/// - treeSitterClient: The tree-sitter client to handle tree updates and highlight queries.
/// - theme: The theme to use for highlights.
init(
- textView: STTextView,
+ textView: TextView,
highlightProvider: HighlightProviding?,
theme: EditorTheme,
attributeProvider: ThemeAttributesProviding,
@@ -82,12 +82,6 @@ class Highlighter: NSObject {
super.init()
- guard textView.textContentStorage?.textStorage != nil else {
- assertionFailure("Text view does not have a textStorage")
- return
- }
-
- textView.textContentStorage?.textStorage?.delegate = self
highlightProvider?.setUp(textView: textView, codeLanguage: language)
if let scrollView = textView.enclosingScrollView {
@@ -165,10 +159,13 @@ private extension Highlighter {
/// Highlights the given range
/// - Parameter range: The range to request highlights for.
func highlight(range rangeToHighlight: NSRange) {
+ print(#function, rangeToHighlight)
pendingSet.insert(integersIn: rangeToHighlight)
- highlightProvider?.queryHighlightsFor(textView: self.textView,
- range: rangeToHighlight) { [weak self] highlightRanges in
+ highlightProvider?.queryHighlightsFor(
+ textView: self.textView,
+ range: rangeToHighlight
+ ) { [weak self] highlightRanges in
guard let attributeProvider = self?.attributeProvider,
let textView = self?.textView else { return }
@@ -178,26 +175,15 @@ private extension Highlighter {
}
self?.validSet.formUnion(IndexSet(integersIn: rangeToHighlight))
- // Try to create a text range for invalidating. If this fails we fail silently
- guard let textContentManager = textView.textLayoutManager.textContentManager,
- let textRange = NSTextRange(rangeToHighlight, provider: textContentManager) else {
- return
- }
-
// Loop through each highlight and modify the textStorage accordingly.
- textView.textContentStorage?.textStorage?.beginEditing()
+ textView.textStorage.beginEditing()
// Create a set of indexes that were not highlighted.
var ignoredIndexes = IndexSet(integersIn: rangeToHighlight)
// Apply all highlights that need color
for highlight in highlightRanges {
- // Does not work:
-// textView.textLayoutManager.setRenderingAttributes(attributeProvider.attributesFor(highlight.capture),
-// for: NSTextRange(highlight.range,
-// provider: textView.textContentStorage)!)
- // Temp solution (until Apple fixes above)
- textView.textContentStorage?.textStorage?.setAttributes(
+ textView.textStorage.setAttributes(
attributeProvider.attributesFor(highlight.capture),
range: highlight.range
)
@@ -210,17 +196,17 @@ private extension Highlighter {
// This fixes the case where characters are changed to have a non-text color, and then are skipped when
// they need to be changed back.
for ignoredRange in ignoredIndexes.rangeView {
- textView.textContentStorage?.textStorage?.setAttributes(
+ textView.textStorage.setAttributes(
attributeProvider.attributesFor(nil),
range: NSRange(ignoredRange)
)
}
- textView.textContentStorage?.textStorage?.endEditing()
+ textView.textStorage.endEditing()
// After applying edits to the text storage we need to invalidate the layout
// of the highlighted text.
- textView.textLayoutManager.invalidateLayout(for: textRange)
+ textView.layoutManager.invalidateLayoutForRange(rangeToHighlight)
}
}
diff --git a/Sources/CodeEditTextView/Highlighting/HighlighterTextView.swift b/Sources/CodeEditTextView/Highlighting/HighlighterTextView.swift
index ff6b2ac7f..b44cef16c 100644
--- a/Sources/CodeEditTextView/Highlighting/HighlighterTextView.swift
+++ b/Sources/CodeEditTextView/Highlighting/HighlighterTextView.swift
@@ -16,3 +16,13 @@ public protocol HighlighterTextView: AnyObject {
/// A substring for the requested range.
func stringForRange(_ nsRange: NSRange) -> String?
}
+
+extension TextView: HighlighterTextView {
+ var documentRange: NSRange {
+ NSRange(location: 0, length: textStorage.length)
+ }
+
+ func stringForRange(_ nsRange: NSRange) -> String? {
+ textStorage.substring(from: nsRange)
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
index 6121c9453..c137b10a3 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -11,6 +11,7 @@ import AppKit
protocol TextLayoutManagerDelegate: AnyObject {
func maxWidthDidChange(newWidth: CGFloat)
func textViewportSize() -> CGSize
+ func textLayoutSetNeedsDisplay()
}
class TextLayoutManager: NSObject {
@@ -132,6 +133,14 @@ class TextLayoutManager: NSObject {
lineStorage.getLine(atPosition: posY)?.node.line
}
+ public func enumerateLines(startingAt posY: CGFloat, completion: ((TextLine, Int, CGFloat) -> Bool)) {
+ for position in lineStorage.linesStartingAt(posY, until: .greatestFiniteMagnitude) {
+ guard completion(position.node.line, position.offset, position.height) else {
+ break
+ }
+ }
+ }
+
// MARK: - Rendering
public func invalidateLayoutForRect(_ rect: NSRect) {
@@ -141,6 +150,12 @@ class TextLayoutManager: NSObject {
}
}
+ public func invalidateLayoutForRange(_ range: NSRange) {
+ for position in lineStorage.linesInRange(range) {
+ position.node.line.typesetter.lineFragments.removeAll(keepingCapacity: true)
+ }
+ }
+
internal func draw(inRect rect: CGRect, context: CGContext) {
// Get all lines in rect & draw!
for position in lineStorage.linesStartingAt(rect.minY, until: rect.maxY) {
@@ -211,6 +226,7 @@ extension TextLayoutManager: NSTextStorageDelegate {
range editedRange: NSRange,
changeInLength delta: Int
) {
-
+ invalidateLayoutForRange(editedRange)
+ delegate?.textLayoutSetNeedsDisplay()
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
index d6bf81704..0fd9a80fb 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
@@ -22,7 +22,9 @@ final class TextLine {
}
func prepareForDisplay(maxWidth: CGFloat) {
- let string = stringRef.attributedSubstring(from: range)
- typesetter.prepareToTypeset(string, maxWidth: maxWidth)
+ typesetter.prepareToTypeset(
+ stringRef.attributedSubstring(from: range),
+ maxWidth: maxWidth
+ )
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Iterator.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Iterator.swift
index 9095ebba8..bae474976 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Iterator.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Iterator.swift
@@ -9,7 +9,11 @@ import Foundation
extension TextLineStorage {
func linesStartingAt(_ minY: CGFloat, until maxY: CGFloat) -> TextLineStorageYIterator {
- return TextLineStorageYIterator(storage: self, minY: minY, maxY: maxY)
+ TextLineStorageYIterator(storage: self, minY: minY, maxY: maxY)
+ }
+
+ func linesInRange(_ range: NSRange) -> TextLineStorageRangeIterator {
+ TextLineStorageRangeIterator(storage: self, range: range)
}
struct TextLineStorageYIterator: Sequence, IteratorProtocol {
@@ -37,4 +41,27 @@ extension TextLineStorage {
}
}
+ struct TextLineStorageRangeIterator: Sequence, IteratorProtocol {
+ let storage: TextLineStorage
+ let range: NSRange
+ var currentPosition: TextLinePosition?
+
+ mutating func next() -> TextLinePosition? {
+ if let currentPosition {
+ guard currentPosition.offset + currentPosition.node.length < NSMaxRange(range),
+ let nextNode = currentPosition.node.getSuccessor() else { return nil }
+ self.currentPosition = TextLinePosition(
+ node: nextNode,
+ offset: currentPosition.offset + currentPosition.node.length,
+ height: currentPosition.height + currentPosition.node.height
+ )
+ return self.currentPosition!
+ } else if let nextPosition = storage.getLine(atIndex: range.location) {
+ self.currentPosition = nextPosition
+ return nextPosition
+ } else {
+ return nil
+ }
+ }
+ }
}
diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift
index 26fd569fe..bd2ec22f0 100644
--- a/Sources/CodeEditTextView/TextView/TextView.swift
+++ b/Sources/CodeEditTextView/TextView/TextView.swift
@@ -24,57 +24,41 @@ class TextView: NSView {
// MARK: - Configuration
func setString(_ string: String) {
- storage.setAttributedString(.init(string: string))
+ textStorage.setAttributedString(.init(string: string))
}
public var font: NSFont
- public var theme: EditorTheme
public var lineHeight: CGFloat
public var wrapLines: Bool
public var editorOverscroll: CGFloat
- public var useThemeBackground: Bool
- public var contentInsets: NSEdgeInsets?
public var isEditable: Bool
public var letterSpacing: Double
// MARK: - Internal Properties
- private var storage: NSTextStorage!
- private var storageDelegate: MultiStorageDelegate!
- private var layoutManager: TextLayoutManager!
+ private(set) var textStorage: NSTextStorage!
+ private(set) var layoutManager: TextLayoutManager!
var scrollView: NSScrollView? {
guard let enclosingScrollView, enclosingScrollView.documentView == self else { return nil }
return enclosingScrollView
}
-// override open var frame: NSRect {
-// get { super.frame }
-// set {
-// super.frame = newValue
-// print(#function)
-// layoutManager.invalidateLayoutForRect(newValue)
-// }
-// }
-
// MARK: - Init
init(
string: String,
font: NSFont,
- theme: EditorTheme,
lineHeight: CGFloat,
wrapLines: Bool,
editorOverscroll: CGFloat,
- useThemeBackground: Bool,
- contentInsets: NSEdgeInsets?,
isEditable: Bool,
- letterSpacing: Double
+ letterSpacing: Double,
+ storageDelegate: MultiStorageDelegate!
) {
- self.storage = NSTextStorage(string: string)
- self.storageDelegate = MultiStorageDelegate()
+ self.textStorage = NSTextStorage(string: string)
self.layoutManager = TextLayoutManager(
- textStorage: storage,
+ textStorage: textStorage,
typingAttributes: [
.font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular),
.paragraphStyle: {
@@ -90,18 +74,14 @@ class TextView: NSView {
)
self.font = font
- self.theme = theme
self.lineHeight = lineHeight
self.wrapLines = wrapLines
self.editorOverscroll = editorOverscroll
- self.useThemeBackground = useThemeBackground
- self.contentInsets = contentInsets
self.isEditable = isEditable
self.letterSpacing = letterSpacing
- storage.delegate = storageDelegate
+ textStorage.delegate = storageDelegate
storageDelegate.addDelegate(layoutManager)
- // TODO: Add Highlighter as storage delegate #2
super.init(frame: .zero)
@@ -136,26 +116,30 @@ class TextView: NSView {
true
}
-// override func makeBackingLayer() -> CALayer {
-// let layer = CETiledLayer()
-// layer.tileSize = CGSize(width: 2000, height: 1000)
-//// layer.levelsOfDetail = 4
-//// layer.levelsOfDetailBias = 2
-// layer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
-// layer.contentsScale = NSScreen.main?.backingScaleFactor ?? 1.0
-// layer.drawsAsynchronously = false
-// return layer
-// }
-
override var visibleRect: NSRect {
if let scrollView = scrollView {
// +200px vertically for a bit of padding
- return scrollView.visibleRect.insetBy(dx: 0, dy: 400)
+ return scrollView.visibleRect.insetBy(dx: 0, dy: -400).offsetBy(dx: 0, dy: 200)
} else {
return super.visibleRect
}
}
+ var visibleTextRange: NSRange? {
+ var min: Int = -1
+ var max: Int = 0
+ layoutManager.enumerateLines(startingAt: CGFloat.maximum(visibleRect.minY, 0)) { _, offset, height in
+ if min < 0 {
+ min = offset
+ } else {
+ max = offset
+ }
+ return height < visibleRect.maxY
+ }
+ guard min >= 0 else { return nil }
+ return NSRange(location: min, length: max - min)
+ }
+
override func draw(_ dirtyRect: NSRect) {
guard let ctx = NSGraphicsContext.current?.cgContext else { return }
layoutManager.draw(inRect: dirtyRect, context: ctx)
@@ -207,4 +191,8 @@ extension TextView: TextLayoutManagerDelegate {
return CGSize(width: CGFloat.infinity, height: CGFloat.infinity)
}
}
+
+ func textLayoutSetNeedsDisplay() {
+ needsDisplay = true
+ }
}
diff --git a/Sources/CodeEditTextView/TextView/TextViewController.swift b/Sources/CodeEditTextView/TextView/TextViewController.swift
index 9180e69eb..7a29ba63d 100644
--- a/Sources/CodeEditTextView/TextView/TextViewController.swift
+++ b/Sources/CodeEditTextView/TextView/TextViewController.swift
@@ -6,44 +6,67 @@
//
import AppKit
+import CodeEditLanguages
+import SwiftUI
+import SwiftTreeSitter
public class TextViewController: NSViewController {
var scrollView: NSScrollView!
var textView: TextView!
- public var string: String
+ public var string: Binding
+ public var language: CodeLanguage
public var font: NSFont
public var theme: EditorTheme
public var lineHeight: CGFloat
public var wrapLines: Bool
+ public var cursorPosition: Binding<(Int, Int)>
public var editorOverscroll: CGFloat
public var useThemeBackground: Bool
+ public var highlightProvider: HighlightProviding?
public var contentInsets: NSEdgeInsets?
public var isEditable: Bool
public var letterSpacing: Double
+ public var bracketPairHighlight: BracketPairHighlight?
+
+ private var storageDelegate: MultiStorageDelegate!
+ private var highlighter: Highlighter?
init(
- string: String,
+ string: Binding,
+ language: CodeLanguage,
font: NSFont,
theme: EditorTheme,
+ tabWidth: Int,
+ indentOption: IndentOption,
lineHeight: CGFloat,
wrapLines: Bool,
+ cursorPosition: Binding<(Int, Int)>,
editorOverscroll: CGFloat,
useThemeBackground: Bool,
+ highlightProvider: HighlightProviding?,
contentInsets: NSEdgeInsets?,
isEditable: Bool,
- letterSpacing: Double
+ letterSpacing: Double,
+ bracketPairHighlight: BracketPairHighlight?
) {
self.string = string
+ self.language = language
self.font = font
self.theme = theme
self.lineHeight = lineHeight
self.wrapLines = wrapLines
+ self.cursorPosition = cursorPosition
self.editorOverscroll = editorOverscroll
self.useThemeBackground = useThemeBackground
+ self.highlightProvider = highlightProvider
self.contentInsets = contentInsets
self.isEditable = isEditable
self.letterSpacing = letterSpacing
+ self.bracketPairHighlight = bracketPairHighlight
+
+ self.storageDelegate = MultiStorageDelegate()
+
super.init(nibName: nil, bundle: nil)
}
@@ -54,16 +77,14 @@ public class TextViewController: NSViewController {
public override func loadView() {
scrollView = NSScrollView()
textView = TextView(
- string: string,
+ string: string.wrappedValue,
font: font,
- theme: theme,
lineHeight: lineHeight,
wrapLines: wrapLines,
editorOverscroll: editorOverscroll,
- useThemeBackground: useThemeBackground,
- contentInsets: contentInsets,
isEditable: isEditable,
- letterSpacing: letterSpacing
+ letterSpacing: letterSpacing,
+ storageDelegate: storageDelegate
)
textView.translatesAutoresizingMaskIntoConstraints = false
@@ -79,6 +100,8 @@ public class TextViewController: NSViewController {
self.view = scrollView
+ setUpHighlighter()
+
NSLayoutConstraint.activate([
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
@@ -94,4 +117,53 @@ public class TextViewController: NSViewController {
self.textView.updateFrameIfNeeded()
}
}
+
+ override public func viewWillAppear() {
+ highlighter?.invalidate()
+ }
+}
+
+extension TextViewController: ThemeAttributesProviding {
+ public func attributesFor(_ capture: CaptureName?) -> [NSAttributedString.Key: Any] {
+ [
+ .font: font,
+ .foregroundColor: theme.colorFor(capture),
+// .baselineOffset: baselineOffset,
+// .paragraphStyle: paragraphStyle,
+// .kern: kern
+ ]
+ }
+}
+
+extension TextViewController {
+ private func setUpHighlighter() {
+ self.highlighter = Highlighter(
+ textView: textView,
+ highlightProvider: highlightProvider,
+ theme: theme,
+ attributeProvider: self,
+ language: language
+ )
+ storageDelegate.addDelegate(highlighter!)
+ setHighlightProvider(self.highlightProvider)
+ }
+
+ internal func setHighlightProvider(_ highlightProvider: HighlightProviding? = nil) {
+ var provider: HighlightProviding?
+
+ if let highlightProvider = highlightProvider {
+ provider = highlightProvider
+ } else {
+ let textProvider: ResolvingQueryCursor.TextProvider = { [weak self] range, _ -> String? in
+ return self?.textView.textStorage.mutableString.substring(with: range)
+ }
+
+ provider = TreeSitterClient(textProvider: textProvider)
+ }
+
+ if let provider = provider {
+ self.highlightProvider = provider
+ highlighter?.setHighlightProvider(provider)
+ }
+ }
}
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 000000000..5e349d7e6
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,32 @@
+# TODO
+
+- [X] load file
+- [X] render text
+- [X] scroll
+- [X] wrap text
+- [X] resize correctly
+- [] syntax highlighting
+- [] edit text
+ - [] isEditable
+- [] tab widths & indents
+- [] update parameters in real time
+ - [] tab & indent options
+ - [] kern
+ - [] theme
+ - [] line height
+ - [] wrap lines
+ - [] editor overscroll
+ - [] useThemeBackground
+ - [] highlight provider
+ - [] content insets
+ - [] isEditable
+ - [] language
+- [] select text
+- [] multiple selection
+- [] copy/paste
+- [] undo/redo
+- [] sync system appearance
+- [] update cursor position
+- [] update text (from outside)
+- [] highlight brackets
+- [] textformation integration
From 6069382563d7726f286e2f15bd0b3511da2b0535 Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Sun, 16 Jul 2023 22:18:39 -0500
Subject: [PATCH 06/75] Syntax highlighting integration (no edit)
---
Sources/CodeEditTextView/TextView/TextView.swift | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift
index bd2ec22f0..37984fc1c 100644
--- a/Sources/CodeEditTextView/TextView/TextView.swift
+++ b/Sources/CodeEditTextView/TextView/TextView.swift
@@ -119,7 +119,7 @@ class TextView: NSView {
override var visibleRect: NSRect {
if let scrollView = scrollView {
// +200px vertically for a bit of padding
- return scrollView.visibleRect.insetBy(dx: 0, dy: -400).offsetBy(dx: 0, dy: 200)
+ return scrollView.documentVisibleRect.insetBy(dx: 0, dy: -400).offsetBy(dx: 0, dy: 200)
} else {
return super.visibleRect
}
From f3d8a5a950c98d89a7e456ba7349504224a3bc4d Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Sun, 16 Jul 2023 22:25:00 -0500
Subject: [PATCH 07/75] Update TODO.md
---
TODO.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/TODO.md b/TODO.md
index 5e349d7e6..cb33cebdc 100644
--- a/TODO.md
+++ b/TODO.md
@@ -5,7 +5,8 @@
- [X] scroll
- [X] wrap text
- [X] resize correctly
-- [] syntax highlighting
+- [x] syntax highlighting
+- [] cursor
- [] edit text
- [] isEditable
- [] tab widths & indents
From d4780dec914f46513470cf47a8e45ad2e655b897 Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Sat, 22 Jul 2023 10:07:12 -0500
Subject: [PATCH 08/75] Begin layer reuse instead of raw draw
---
.../Highlighting/Highlighter.swift | 1 -
.../{Typesetter => }/LineFragment.swift | 4 +-
.../TextLayoutManager/LineFragmentLayer.swift | 35 ++++++
.../TextLayoutManager/TextLayoutManager.swift | 85 +++++++++------
.../{Typesetter => }/Typesetter.swift | 0
.../TextLineStorage+Cache.swift | 0
.../TextLineStorage+Iterator.swift | 22 ++--
.../TextLineStorage+Node.swift | 35 +++---
.../TextLineStorage/TextLineStorage.swift | 42 +++----
.../TextSelectionManager/CursorLayer.swift | 21 ++++
.../TextSelectionManager.swift | 74 +++++++++++++
.../TextView/TextView+NSTextInput.swift | 68 ++++++++++++
.../TextView/{ => TextView}/TextView.swift | 103 ++++++++++++++----
.../TextView/TextViewController.swift | 4 -
14 files changed, 383 insertions(+), 111 deletions(-)
rename Sources/CodeEditTextView/TextView/TextLayoutManager/{Typesetter => }/LineFragment.swift (60%)
create mode 100644 Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentLayer.swift
rename Sources/CodeEditTextView/TextView/TextLayoutManager/{Typesetter => }/Typesetter.swift (100%)
rename Sources/CodeEditTextView/TextView/{TextLayoutManager => }/TextLineStorage/TextLineStorage+Cache.swift (100%)
rename Sources/CodeEditTextView/TextView/{TextLayoutManager => }/TextLineStorage/TextLineStorage+Iterator.swift (67%)
rename Sources/CodeEditTextView/TextView/{TextLayoutManager => }/TextLineStorage/TextLineStorage+Node.swift (77%)
rename Sources/CodeEditTextView/TextView/{TextLayoutManager => }/TextLineStorage/TextLineStorage.swift (95%)
create mode 100644 Sources/CodeEditTextView/TextView/TextSelectionManager/CursorLayer.swift
create mode 100644 Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
create mode 100644 Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
rename Sources/CodeEditTextView/TextView/{ => TextView}/TextView.swift (66%)
diff --git a/Sources/CodeEditTextView/Highlighting/Highlighter.swift b/Sources/CodeEditTextView/Highlighting/Highlighter.swift
index 45edce9fc..aa38a5b0a 100644
--- a/Sources/CodeEditTextView/Highlighting/Highlighter.swift
+++ b/Sources/CodeEditTextView/Highlighting/Highlighter.swift
@@ -159,7 +159,6 @@ private extension Highlighter {
/// Highlights the given range
/// - Parameter range: The range to request highlights for.
func highlight(range rangeToHighlight: NSRange) {
- print(#function, rangeToHighlight)
pendingSet.insert(integersIn: rangeToHighlight)
highlightProvider?.queryHighlightsFor(
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/LineFragment.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragment.swift
similarity index 60%
rename from Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/LineFragment.swift
rename to Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragment.swift
index ecb788d28..b573bfb82 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/LineFragment.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragment.swift
@@ -11,10 +11,12 @@ struct LineFragment {
var ctLine: CTLine
var width: CGFloat
var height: CGFloat
+ var scaledHeight: CGFloat
- init(ctLine: CTLine, width: CGFloat, height: CGFloat) {
+ init(ctLine: CTLine, width: CGFloat, height: CGFloat, lineHeightMultiplier: CGFloat) {
self.ctLine = ctLine
self.width = width
self.height = height
+ self.scaledHeight = height * lineHeightMultiplier
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentLayer.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentLayer.swift
new file mode 100644
index 000000000..8b982c45c
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentLayer.swift
@@ -0,0 +1,35 @@
+//
+// LineFragmentLayer.swift
+//
+//
+// Created by Khan Winter on 7/20/23.
+//
+
+import AppKit
+
+class LineFragmentLayer: CALayer {
+ private var lineFragment: LineFragment
+
+ init(lineFragment: LineFragment) {
+ self.lineFragment = lineFragment
+ super.init()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ func prepareForReuse(lineFragment: LineFragment) {
+ self.lineFragment = lineFragment
+ self.frame.size = CGSize(width: lineFragment.width, height: lineFragment.height)
+ }
+
+ override func draw(in ctx: CGContext) {
+ ctx.saveGState()
+ ctx.textMatrix = .init(scaleX: 1, y: -1)
+ ctx.translateBy(x: 0, y: lineFragment.height + (lineFragment.scaledHeight / 2))
+ ctx.textPosition = CGPoint(x: 0, y: (lineFragment.scaledHeight - lineFragment.height) / 2)
+ CTLineDraw(lineFragment.ctLine, ctx)
+ ctx.restoreGState()
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
index c137b10a3..6cefc5fd5 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -25,7 +25,7 @@ class TextLayoutManager: NSObject {
// MARK: - Internal
private unowned var textStorage: NSTextStorage
- private var lineStorage: TextLineStorage
+ private var lineStorage: TextLineStorage
private var maxLineWidth: CGFloat = 0 {
didSet {
@@ -116,7 +116,7 @@ class TextLayoutManager: NSObject {
return (ascent + descent + leading) * lineHeightMultiplier
}
- // MARK: - Public Convenience Methods
+ // MARK: - Public Methods
public func estimatedHeight() -> CGFloat {
guard let position = lineStorage.getLine(atIndex: lineStorage.length - 1) else {
@@ -130,55 +130,77 @@ class TextLayoutManager: NSObject {
}
public func textLineForPosition(_ posY: CGFloat) -> TextLine? {
- lineStorage.getLine(atPosition: posY)?.node.line
+ lineStorage.getLine(atPosition: posY)?.node.data
}
- public func enumerateLines(startingAt posY: CGFloat, completion: ((TextLine, Int, CGFloat) -> Bool)) {
- for position in lineStorage.linesStartingAt(posY, until: .greatestFiniteMagnitude) {
- guard completion(position.node.line, position.offset, position.height) else {
- break
+ public func textOffsetAtPoint(_ point: CGPoint) -> Int? {
+ guard let position = lineStorage.getLine(atPosition: point.y) else {
+ return nil
+ }
+ // Find the fragment that contains the point
+ var height: CGFloat = position.height
+ for fragment in position.node.data.typesetter.lineFragments {
+ if point.y >= height && point.y <= height + (fragment.height * lineHeightMultiplier) {
+ let fragmentRange = CTLineGetStringRange(fragment.ctLine)
+ if fragment.width < point.x {
+ return position.offset + fragmentRange.location + fragmentRange.length
+ } else {
+ let fragmentIndex = CTLineGetStringIndexForPosition(
+ fragment.ctLine,
+ CGPoint(x: point.x, y: fragment.height/2)
+ )
+ return position.offset + fragmentRange.location + fragmentIndex
+ }
+ } else if height > point.y {
+ return nil
}
+ height += fragment.height * lineHeightMultiplier
}
+
+ return nil
}
- // MARK: - Rendering
+ // MARK: - Drawing
public func invalidateLayoutForRect(_ rect: NSRect) {
// Get all lines in rect and discard their line fragment data
for position in lineStorage.linesStartingAt(rect.minY, until: rect.maxY) {
- position.node.line.typesetter.lineFragments.removeAll(keepingCapacity: true)
+ position.node.data.typesetter.lineFragments.removeAll(keepingCapacity: true)
}
}
public func invalidateLayoutForRange(_ range: NSRange) {
for position in lineStorage.linesInRange(range) {
- position.node.line.typesetter.lineFragments.removeAll(keepingCapacity: true)
+ position.node.data.typesetter.lineFragments.removeAll(keepingCapacity: true)
}
}
- internal func draw(inRect rect: CGRect, context: CGContext) {
- // Get all lines in rect & draw!
- for position in lineStorage.linesStartingAt(rect.minY, until: rect.maxY) {
- let lineSize = drawLine(
- line: position.node.line,
- offsetHeight: position.height,
- minY: rect.minY,
- maxY: rect.maxY,
- context: context
- )
- if lineSize.height != position.node.height {
- lineStorage.update(
- atIndex: position.offset,
- delta: 0,
- deltaHeight: lineSize.height - position.node.height
- )
- }
- if maxLineWidth < lineSize.width {
- maxLineWidth = lineSize.width
- }
- }
+ internal func layoutLines() {
+ // Flush all line fragment views for reuse and
}
+// internal func draw(inRect rect: CGRect, context: CGContext) {
+// // Get all lines in rect & draw!
+// for position in lineStorage.linesStartingAt(rect.minY, until: rect.maxY + 200) {
+// let lineSize = drawLine(
+// line: position.node.data,
+// offsetHeight: position.height,
+// minY: rect.minY,
+// context: context
+// )
+// if lineSize.height != position.node.height {
+// lineStorage.update(
+// atIndex: position.offset,
+// delta: 0,
+// deltaHeight: lineSize.height - position.node.height
+// )
+// }
+// if maxLineWidth < lineSize.width {
+// maxLineWidth = lineSize.width
+// }
+// }
+// }
+
/// Draws a `TextLine` into the current graphics context up to a maximum y position.
/// - Parameters:
/// - line: The line to draw.
@@ -190,7 +212,6 @@ class TextLayoutManager: NSObject {
line: TextLine,
offsetHeight: CGFloat,
minY: CGFloat,
- maxY: CGFloat,
context: CGContext
) -> CGSize {
if line.typesetter.lineFragments.isEmpty {
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/Typesetter.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter.swift
similarity index 100%
rename from Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter/Typesetter.swift
rename to Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter.swift
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Cache.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Cache.swift
similarity index 100%
rename from Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Cache.swift
rename to Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Cache.swift
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Iterator.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Iterator.swift
similarity index 67%
rename from Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Iterator.swift
rename to Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Iterator.swift
index bae474976..ba6bc0803 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Iterator.swift
+++ b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Iterator.swift
@@ -24,13 +24,11 @@ extension TextLineStorage {
mutating func next() -> TextLinePosition? {
if let currentPosition {
- guard currentPosition.height + currentPosition.node.height < maxY,
- let nextNode = currentPosition.node.getSuccessor() else { return nil }
- self.currentPosition = TextLinePosition(
- node: nextNode,
- offset: currentPosition.offset + currentPosition.node.length,
- height: currentPosition.height + currentPosition.node.height
- )
+ guard currentPosition.height < maxY,
+ let nextPosition = storage.getLine(
+ atIndex: currentPosition.offset + currentPosition.node.length
+ ) else { return nil }
+ self.currentPosition = nextPosition
return self.currentPosition!
} else if let nextPosition = storage.getLine(atPosition: minY) {
self.currentPosition = nextPosition
@@ -49,12 +47,10 @@ extension TextLineStorage {
mutating func next() -> TextLinePosition? {
if let currentPosition {
guard currentPosition.offset + currentPosition.node.length < NSMaxRange(range),
- let nextNode = currentPosition.node.getSuccessor() else { return nil }
- self.currentPosition = TextLinePosition(
- node: nextNode,
- offset: currentPosition.offset + currentPosition.node.length,
- height: currentPosition.height + currentPosition.node.height
- )
+ let nextPosition = storage.getLine(
+ atIndex: currentPosition.offset + currentPosition.node.length
+ ) else { return nil }
+ self.currentPosition = nextPosition
return self.currentPosition!
} else if let nextPosition = storage.getLine(atIndex: range.location) {
self.currentPosition = nextPosition
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Node.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift
similarity index 77%
rename from Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Node.swift
rename to Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift
index 05c918444..4e2b8fca3 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage+Node.swift
+++ b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift
@@ -8,15 +8,15 @@
import Foundation
extension TextLineStorage {
- func isRightChild(_ node: Node) -> Bool {
+ func isRightChild(_ node: Node) -> Bool {
node.parent?.right == node
}
- func isLeftChild(_ node: Node) -> Bool {
+ func isLeftChild(_ node: Node) -> Bool {
node.parent?.left == node
}
- func sibling(_ node: Node) -> Node? {
+ func sibling(_ node: Node) -> Node? {
if isLeftChild(node) {
return node.parent?.right
} else {
@@ -24,40 +24,41 @@ extension TextLineStorage {
}
}
- final class Node: Equatable {
+ final class Node: Equatable {
enum Color {
case red
case black
}
+ let id: UUID = UUID()
+
// The length of the text line
var length: Int
- var id: UUID = UUID()
- var line: TextLine
+ var data: Data
// The offset in characters of the entire left subtree
var leftSubtreeOffset: Int
var leftSubtreeHeight: CGFloat
var height: CGFloat
- var left: Node?
- var right: Node?
- unowned var parent: Node?
+ var left: Node?
+ var right: Node?
+ unowned var parent: Node?
var color: Color
init(
length: Int,
- line: TextLine,
+ data: Data,
leftSubtreeOffset: Int,
leftSubtreeHeight: CGFloat,
height: CGFloat,
- left: Node? = nil,
- right: Node? = nil,
- parent: Node? = nil,
+ left: Node? = nil,
+ right: Node? = nil,
+ parent: Node? = nil,
color: Color
) {
self.length = length
- self.line = line
+ self.data = data
self.leftSubtreeOffset = leftSubtreeOffset
self.leftSubtreeHeight = leftSubtreeHeight
self.height = height
@@ -71,7 +72,7 @@ extension TextLineStorage {
lhs.id == rhs.id
}
- func minimum() -> Node? {
+ func minimum() -> Node? {
if let left {
return left.minimum()
} else {
@@ -79,7 +80,7 @@ extension TextLineStorage {
}
}
- func maximum() -> Node? {
+ func maximum() -> Node? {
if let right {
return right.maximum()
} else {
@@ -87,7 +88,7 @@ extension TextLineStorage {
}
}
- func getSuccessor() -> Node? {
+ func getSuccessor() -> Node? {
// If node has right child: successor is the min of this right tree
if let right {
return right.minimum()
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
similarity index 95%
rename from Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage.swift
rename to Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
index 48dbd406b..f12c8d7c3 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLineStorage/TextLineStorage.swift
+++ b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
@@ -8,17 +8,17 @@
import Foundation
/// Implements a red-black tree for efficiently editing, storing and retrieving `TextLine`s.
-final class TextLineStorage {
+final class TextLineStorage {
struct TextLinePosition {
- let node: Node
+ let node: Node
let offset: Int
let height: CGFloat
}
#if DEBUG
- var root: Node?
+ var root: Node?
#else
- private var root: Node?
+ private var root: Node?
#endif
/// The number of characters in the storage object.
private(set) public var length: Int = 0
@@ -33,7 +33,7 @@ final class TextLineStorage {
/// - Parameters:
/// - line: The text line to insert
/// - range: The range the line represents. If the range is empty the line will be ignored.
- public func insert(line: TextLine, atIndex index: Int, length: Int, height: CGFloat) {
+ public func insert(line: Data, atIndex index: Int, length: Int, height: CGFloat) {
assert(index >= 0 && index <= self.length, "Invalid index, expected between 0 and \(self.length). Got \(index)")
defer {
self.count += 1
@@ -42,7 +42,7 @@ final class TextLineStorage {
let insertedNode = Node(
length: length,
- line: line,
+ data: line,
leftSubtreeOffset: 0,
leftSubtreeHeight: 0.0,
height: height,
@@ -181,7 +181,7 @@ final class TextLineStorage {
/// Efficiently builds the tree from the given array of lines.
/// - Parameter lines: The lines to use to build the tree.
- public func build(from lines: [(TextLine, Int)], estimatedLineHeight: CGFloat) {
+ public func build(from lines: [(Data, Int)], estimatedLineHeight: CGFloat) {
root = build(lines: lines, estimatedLineHeight: estimatedLineHeight, left: 0, right: lines.count, parent: nil).0
count = lines.count
}
@@ -195,17 +195,17 @@ final class TextLineStorage {
/// - parent: The parent of the subtree, `nil` if this is the root.
/// - Returns: A node, if available, along with it's subtree's height and offset.
private func build(
- lines: [(TextLine, Int)],
+ lines: [(Data, Int)],
estimatedLineHeight: CGFloat,
left: Int,
right: Int,
- parent: Node?
- ) -> (Node?, Int?, CGFloat?) { // swiftlint:disable:this large_tuple
+ parent: Node?
+ ) -> (Node?, Int?, CGFloat?) { // swiftlint:disable:this large_tuple
guard left < right else { return (nil, nil, nil) }
let mid = left + (right - left)/2
let node = Node(
length: lines[mid].1,
- line: lines[mid].0,
+ data: lines[mid].0,
leftSubtreeOffset: 0,
leftSubtreeHeight: 0,
height: estimatedLineHeight,
@@ -252,7 +252,7 @@ private extension TextLineStorage {
/// Searches for the given index. Returns a node and offset if found.
/// - Parameter index: The index to look for in the document.
/// - Returns: A tuple containing a node if it was found, and the offset of the node in the document.
- func search(for index: Int) -> TextLinePosition? { // swiftlint:disable:this large_tuple
+ func search(for index: Int) -> TextLinePosition? {
var currentNode = root
var currentOffset: Int = root?.leftSubtreeOffset ?? 0
var currentHeight: CGFloat = root?.leftSubtreeHeight ?? 0
@@ -278,10 +278,10 @@ private extension TextLineStorage {
// MARK: - Fixup
- func insertFixup(node: Node) {
+ func insertFixup(node: Node) {
metaFixup(startingAt: node, delta: node.length, deltaHeight: node.height)
- var nextNode: Node? = node
+ var nextNode: Node? = node
while var nodeX = nextNode, nodeX != root, let nodeXParent = nodeX.parent, nodeXParent.color == .red {
let nodeY = sibling(nodeXParent)
if isLeftChild(nodeXParent) {
@@ -327,12 +327,12 @@ private extension TextLineStorage {
}
/// RB Tree Deletes `:(`
- func deleteFixup(node: Node) {
+ func deleteFixup(node: Node) {
}
/// Walk up the tree, updating any `leftSubtree` metadata.
- func metaFixup(startingAt node: Node, delta: Int, deltaHeight: CGFloat) {
+ func metaFixup(startingAt node: Node, delta: Int, deltaHeight: CGFloat) {
guard node.parent != nil, delta > 0 else { return }
var node: Node? = node
while node != nil, node != root {
@@ -344,7 +344,7 @@ private extension TextLineStorage {
}
}
- func calculateSize(_ node: Node?) -> Int {
+ func calculateSize(_ node: Node?) -> Int {
guard let node else { return 0 }
return node.length + node.leftSubtreeOffset + calculateSize(node.right)
}
@@ -353,16 +353,16 @@ private extension TextLineStorage {
// MARK: - Rotations
private extension TextLineStorage {
- func rightRotate(node: Node) {
+ func rightRotate(node: Node) {
rotate(node: node, left: false)
}
- func leftRotate(node: Node) {
+ func leftRotate(node: Node) {
rotate(node: node, left: true)
}
- func rotate(node: Node, left: Bool) {
- var nodeY: Node?
+ func rotate(node: Node, left: Bool) {
+ var nodeY: Node?
if left {
nodeY = node.right
diff --git a/Sources/CodeEditTextView/TextView/TextSelectionManager/CursorLayer.swift b/Sources/CodeEditTextView/TextView/TextSelectionManager/CursorLayer.swift
new file mode 100644
index 000000000..06926ce8c
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextSelectionManager/CursorLayer.swift
@@ -0,0 +1,21 @@
+//
+// CursorLayer.swift
+//
+//
+// Created by Khan Winter on 7/17/23.
+//
+
+import AppKit
+
+class CursorLayer: CALayer {
+ let rect: NSRect
+
+ init(rect: NSRect) {
+ self.rect = rect
+ super.init()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
new file mode 100644
index 000000000..bd7930e9e
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
@@ -0,0 +1,74 @@
+//
+// TextSelectionManager.swift
+//
+//
+// Created by Khan Winter on 7/17/23.
+//
+
+import AppKit
+
+/// Manages an array of text selections representing cursors (0-length ranges) and selections (>0-length ranges).
+///
+/// Draws selections using a draw method similar to the `TextLayoutManager` class, and adds
+class TextSelectionManager {
+ struct MarkedText {
+ let range: NSRange
+ let attributedString: NSAttributedString
+ }
+
+ class TextSelection {
+ var range: NSRange
+ weak var layer: CALayer?
+
+ init(range: NSRange, layer: CALayer? = nil) {
+ self.range = range
+ self.layer = layer
+ }
+
+ var isCursor: Bool {
+ range.length == 0
+ }
+ }
+
+ private(set) var markedText: [MarkedText] = []
+ private(set) var textSelections: [TextSelection] = []
+ private unowned var layoutManager: TextLayoutManager
+
+ init(layoutManager: TextLayoutManager) {
+ self.layoutManager = layoutManager
+ textSelections = [
+ TextSelection(range: NSRange(location: 0, length: 0))
+ ]
+// updateSelectionLayers()
+ }
+
+ public func setSelectedRange(_ range: NSRange) {
+ textSelections = [TextSelection(range: range)]
+// updateSelectionLayers()
+ layoutManager.delegate?.textLayoutSetNeedsDisplay()
+ }
+
+ public func setSelectedRanges(_ ranges: [NSRange]) {
+ textSelections = ranges.map { TextSelection(range: $0) }
+// updateSelectionLayers()
+ layoutManager.delegate?.textLayoutSetNeedsDisplay()
+ }
+
+ /// Updates all cursor layers.
+// private func updateSelectionLayers() {
+// for textSelection in textSelections {
+// if textSelection.isCursor {
+// textSelection.layer?.removeFromSuperlayer()
+//// let rect =
+//// let layer = CursorLayer(rect: <#T##NSRect#>)
+// }
+// }
+// }
+
+ // MARK: - Draw
+
+ /// Draws all visible highlight rects.
+// internal func draw(inRect rect: CGRect, context: CGContext) {
+//
+// }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift b/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
new file mode 100644
index 000000000..b914984ec
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
@@ -0,0 +1,68 @@
+//
+// TextView+NSTextInput.swift
+//
+//
+// Created by Khan Winter on 7/16/23.
+//
+
+import AppKit
+
+/**
+ # Marked Text Notes
+
+ Marked text is used when a character may need more than one keystroke to insert text. For example pressing option-e
+ then e again to insert the é character.
+
+ The text view needs to maintain a range of marked text and apply attributes indicating the text is marked. When
+ selection is updated, the marked text range can be discarded if the cursor leaves the marked text range.
+
+ ## Notes for multiple cursors
+
+ When inserting using multiple cursors, the marked text should be duplicated across all insertion points. However
+ this should only happen if the `setMarkedText` method is called with `NSNotFound` for the replacement range's
+ location (indicating that the marked text should appear at the insertion location)
+
+ **Note: Visual studio code Does not correctly support marked text, use Xcode as an example of this behavior.*
+ */
+
+extension TextView: NSTextInputClient {
+ func insertText(_ string: Any, replacementRange: NSRange) {
+ print(string, replacementRange)
+ }
+
+ func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
+
+ }
+
+ func unmarkText() {
+
+ }
+
+ func selectedRange() -> NSRange {
+ .zero
+ }
+
+ func markedRange() -> NSRange {
+ .zero
+ }
+
+ func hasMarkedText() -> Bool {
+ false
+ }
+
+ func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? {
+ nil
+ }
+
+ func validAttributesForMarkedText() -> [NSAttributedString.Key] {
+ []
+ }
+
+ func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect {
+ .zero
+ }
+
+ func characterIndex(for point: NSPoint) -> Int {
+ layoutManager.textOffsetAtPoint(point) ?? NSNotFound
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
similarity index 66%
rename from Sources/CodeEditTextView/TextView/TextView.swift
rename to Sources/CodeEditTextView/TextView/TextView/TextView.swift
index 37984fc1c..572e0ed3a 100644
--- a/Sources/CodeEditTextView/TextView/TextView.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
@@ -12,15 +12,17 @@ import STTextView
```
TextView
- |-> LayoutManager Creates, manages, and renders TextLines from the text storage
+ |-> TextLayoutManager Creates, manages, and lays out text lines from a line storage
| |-> [TextLine] Represents a text line
| | |-> Typesetter Lays out and calculates line fragments
- | | | |-> [LineFragment] Represents a visual text line (may be multiple if text wrapping is on)
- |-> SelectionManager (depends on LayoutManager) Maintains text selections and renders selections
+ | | |-> [LineFragment] Represents a visual text line, stored in a line storage for long lines
+ | |-> [LineFragmentLayer] Reusable fragment layers that draw a fragment in their context.
+ |
+ |-> TextSelectionManager (depends on LayoutManager) Maintains text selections and renders selections
| |-> [TextSelection]
```
*/
-class TextView: NSView {
+class TextView: NSView, NSTextContent {
// MARK: - Configuration
func setString(_ string: String) {
@@ -32,12 +34,24 @@ class TextView: NSView {
public var wrapLines: Bool
public var editorOverscroll: CGFloat
public var isEditable: Bool
+ public var isSelectable: Bool = true {
+ didSet {
+ if isSelectable, let layer = self.layer {
+ self.selectionManager = TextSelectionManager(layoutManager: layoutManager, parentLayer: layer)
+ } else {
+ self.selectionManager = nil
+ }
+ }
+ }
public var letterSpacing: Double
+ open var contentType: NSTextContentType?
+
// MARK: - Internal Properties
private(set) var textStorage: NSTextStorage!
private(set) var layoutManager: TextLayoutManager!
+ private(set) var selectionManager: TextSelectionManager?
var scrollView: NSScrollView? {
guard let enclosingScrollView, enclosingScrollView.documentView == self else { return nil }
@@ -90,16 +104,40 @@ class TextView: NSView {
wantsLayer = true
postsFrameChangedNotifications = true
postsBoundsChangedNotifications = true
-
autoresizingMask = [.width, .height]
updateFrameIfNeeded()
+
+ if isSelectable {
+ self.selectionManager = TextSelectionManager(layoutManager: layoutManager)
+ }
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
+ open override var canBecomeKeyView: Bool {
+ super.canBecomeKeyView && acceptsFirstResponder && !isHiddenOrHasHiddenAncestor
+ }
+
+ open override var needsPanelToBecomeKey: Bool {
+ isSelectable || isEditable
+ }
+
+ open override var acceptsFirstResponder: Bool {
+ isSelectable
+ }
+
+ open override func resetCursorRects() {
+ super.resetCursorRects()
+ if isSelectable {
+ addCursorRect(visibleRect, cursor: .iBeam)
+ }
+ }
+
+ // MARK: - View Lifecycle
+
override func viewWillMove(toWindow newWindow: NSWindow?) {
super.viewWillMove(toWindow: newWindow)
updateFrameIfNeeded()
@@ -110,6 +148,35 @@ class TextView: NSView {
updateFrameIfNeeded()
}
+ override func layout() {
+ if needsLayout {
+ needsLayout = false
+ CATransaction.begin()
+ layoutManager.layoutLines()
+ CATransaction.commit()
+ }
+ }
+
+ // MARK: - Keys
+
+ override func keyDown(with event: NSEvent) {
+ guard isEditable else {
+ super.keyDown(with: event)
+ return
+ }
+
+ NSCursor.setHiddenUntilMouseMoves(true)
+
+ if !(inputContext?.handleEvent(event) ?? false) {
+ interpretKeyEvents([event])
+ }
+ }
+
+ override func mouseDown(with event: NSEvent) {
+ super.mouseDown(with: event)
+
+ }
+
// MARK: - Draw
override open var isFlipped: Bool {
@@ -126,23 +193,16 @@ class TextView: NSView {
}
var visibleTextRange: NSRange? {
- var min: Int = -1
- var max: Int = 0
- layoutManager.enumerateLines(startingAt: CGFloat.maximum(visibleRect.minY, 0)) { _, offset, height in
- if min < 0 {
- min = offset
- } else {
- max = offset
- }
- return height < visibleRect.maxY
+ let minY = max(visibleRect.minY, 0)
+ let maxY = min(visibleRect.maxY, layoutManager.estimatedHeight())
+ guard let minYLine = layoutManager.textLineForPosition(minY),
+ let maxYLine = layoutManager.textLineForPosition(maxY) else {
+ return nil
}
- guard min >= 0 else { return nil }
- return NSRange(location: min, length: max - min)
- }
-
- override func draw(_ dirtyRect: NSRect) {
- guard let ctx = NSGraphicsContext.current?.cgContext else { return }
- layoutManager.draw(inRect: dirtyRect, context: ctx)
+ return NSRange(
+ location: minYLine.range.location,
+ length: (maxYLine.range.location - minYLine.range.location) + maxYLine.range.length
+ )
}
public func updateFrameIfNeeded() {
@@ -170,7 +230,6 @@ class TextView: NSView {
if didUpdate {
needsLayout = true
needsDisplay = true
- layoutManager.invalidateLayoutForRect(frame)
}
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextViewController.swift b/Sources/CodeEditTextView/TextView/TextViewController.swift
index 7a29ba63d..ee0fda29c 100644
--- a/Sources/CodeEditTextView/TextView/TextViewController.swift
+++ b/Sources/CodeEditTextView/TextView/TextViewController.swift
@@ -117,10 +117,6 @@ public class TextViewController: NSViewController {
self.textView.updateFrameIfNeeded()
}
}
-
- override public func viewWillAppear() {
- highlighter?.invalidate()
- }
}
extension TextViewController: ThemeAttributesProviding {
From 6678dc4fc057bd87f16a0f056a6997679ab00e0b Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Tue, 15 Aug 2023 23:45:55 -0500
Subject: [PATCH 09/75] Use reusable views for rendering lines
---
Package.swift | 7 +-
.../TextLayoutManager/LineFragment.swift | 16 +-
.../TextLayoutManager/LineFragmentLayer.swift | 35 --
.../TextLayoutManager/LineFragmentView.swift | 40 +++
.../TextLayoutManager/TextLayoutManager.swift | 298 ++++++++++++------
.../TextView/TextLayoutManager/TextLine.swift | 8 +-
.../TextLayoutManager/Typesetter.swift | 24 +-
.../TextLineStorage+Iterator.swift | 27 ++
.../TextLineStorage+Node.swift | 6 +-
.../TextLineStorage/TextLineStorage.swift | 15 +-
.../TextView/TextView/TextView.swift | 88 +++---
.../TextView/TextViewController.swift | 7 +-
.../{ => Utils}/MultiStorageDelegate.swift | 0
.../TextView/Utils/ViewReuseQueue.swift | 71 +++++
14 files changed, 444 insertions(+), 198 deletions(-)
delete mode 100644 Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentLayer.swift
create mode 100644 Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentView.swift
rename Sources/CodeEditTextView/TextView/{ => Utils}/MultiStorageDelegate.swift (100%)
create mode 100644 Sources/CodeEditTextView/TextView/Utils/ViewReuseQueue.swift
diff --git a/Package.swift b/Package.swift
index 6a1569615..97082fe58 100644
--- a/Package.swift
+++ b/Package.swift
@@ -28,6 +28,10 @@ let package = Package(
.package(
url: "https://github.com/ChimeHQ/TextFormation",
from: "0.7.0"
+ ),
+ .package(
+ url: "https://github.com/apple/swift-collections.git",
+ .upToNextMajor(from: "1.0.0")
)
],
targets: [
@@ -36,7 +40,8 @@ let package = Package(
dependencies: [
"STTextView",
"CodeEditLanguages",
- "TextFormation"
+ "TextFormation",
+ .product(name: "Collections", package: "swift-collections")
],
plugins: [
.plugin(name: "SwiftLint", package: "SwiftLintPlugin")
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragment.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragment.swift
index b573bfb82..150ff6a34 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragment.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragment.swift
@@ -7,13 +7,19 @@
import AppKit
-struct LineFragment {
+class LineFragment: Identifiable {
+ let id = UUID()
var ctLine: CTLine
- var width: CGFloat
- var height: CGFloat
- var scaledHeight: CGFloat
+ let width: CGFloat
+ let height: CGFloat
+ let scaledHeight: CGFloat
- init(ctLine: CTLine, width: CGFloat, height: CGFloat, lineHeightMultiplier: CGFloat) {
+ init(
+ ctLine: CTLine,
+ width: CGFloat,
+ height: CGFloat,
+ lineHeightMultiplier: CGFloat
+ ) {
self.ctLine = ctLine
self.width = width
self.height = height
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentLayer.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentLayer.swift
deleted file mode 100644
index 8b982c45c..000000000
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentLayer.swift
+++ /dev/null
@@ -1,35 +0,0 @@
-//
-// LineFragmentLayer.swift
-//
-//
-// Created by Khan Winter on 7/20/23.
-//
-
-import AppKit
-
-class LineFragmentLayer: CALayer {
- private var lineFragment: LineFragment
-
- init(lineFragment: LineFragment) {
- self.lineFragment = lineFragment
- super.init()
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- func prepareForReuse(lineFragment: LineFragment) {
- self.lineFragment = lineFragment
- self.frame.size = CGSize(width: lineFragment.width, height: lineFragment.height)
- }
-
- override func draw(in ctx: CGContext) {
- ctx.saveGState()
- ctx.textMatrix = .init(scaleX: 1, y: -1)
- ctx.translateBy(x: 0, y: lineFragment.height + (lineFragment.scaledHeight / 2))
- ctx.textPosition = CGPoint(x: 0, y: (lineFragment.scaledHeight - lineFragment.height) / 2)
- CTLineDraw(lineFragment.ctLine, ctx)
- ctx.restoreGState()
- }
-}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentView.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentView.swift
new file mode 100644
index 000000000..d3515fd66
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentView.swift
@@ -0,0 +1,40 @@
+//
+// LineFragmentView.swift
+//
+//
+// Created by Khan Winter on 8/14/23.
+//
+
+import AppKit
+
+class LineFragmentView: NSView {
+ private weak var lineFragment: LineFragment?
+
+ override var isFlipped: Bool {
+ true
+ }
+
+ override func prepareForReuse() {
+ super.prepareForReuse()
+ lineFragment = nil
+ }
+
+ public func setLineFragment(_ newFragment: LineFragment) {
+ self.lineFragment = newFragment
+ self.frame.size = CGSize(width: newFragment.width, height: newFragment.scaledHeight)
+ }
+
+ override func draw(_ dirtyRect: NSRect) {
+ guard let lineFragment, let ctx = NSGraphicsContext.current?.cgContext else {
+ return
+ }
+ ctx.saveGState()
+ ctx.textMatrix = .init(scaleX: 1, y: -1)
+ ctx.textPosition = CGPoint(
+ x: 0,
+ y: lineFragment.height + ((lineFragment.height - lineFragment.scaledHeight) / 2)
+ )
+ CTLineDraw(lineFragment.ctLine, ctx)
+ ctx.restoreGState()
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
index 6cefc5fd5..16f3d4e3b 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -9,9 +9,12 @@ import Foundation
import AppKit
protocol TextLayoutManagerDelegate: AnyObject {
- func maxWidthDidChange(newWidth: CGFloat)
- func textViewportSize() -> CGSize
+ func layoutManagerHeightDidUpdate(newHeight: CGFloat)
+ func layoutManagerMaxWidthDidChange(newWidth: CGFloat)
+ func textViewSize() -> CGSize
func textLayoutSetNeedsDisplay()
+
+ var visibleRect: NSRect { get }
}
class TextLayoutManager: NSObject {
@@ -25,11 +28,14 @@ class TextLayoutManager: NSObject {
// MARK: - Internal
private unowned var textStorage: NSTextStorage
- private var lineStorage: TextLineStorage
+ private var lineStorage: TextLineStorage = TextLineStorage()
+ private let viewReuseQueue: ViewReuseQueue = ViewReuseQueue()
+
+ weak private var layoutView: NSView?
private var maxLineWidth: CGFloat = 0 {
didSet {
- delegate?.maxWidthDidChange(newWidth: maxLineWidth)
+ delegate?.layoutManagerMaxWidthDidChange(newWidth: maxLineWidth)
}
}
@@ -43,13 +49,16 @@ class TextLayoutManager: NSObject {
textStorage: NSTextStorage,
typingAttributes: [NSAttributedString.Key: Any],
lineHeightMultiplier: CGFloat,
- wrapLines: Bool
+ wrapLines: Bool,
+ textView: NSView,
+ delegate: TextLayoutManagerDelegate?
) {
self.textStorage = textStorage
- self.lineStorage = TextLineStorage()
self.typingAttributes = typingAttributes
self.lineHeightMultiplier = lineHeightMultiplier
self.wrapLines = wrapLines
+ self.layoutView = textView
+ self.delegate = delegate
super.init()
textStorage.addAttributes(typingAttributes, range: NSRange(location: 0, length: textStorage.length))
prepareTextLines()
@@ -59,11 +68,11 @@ class TextLayoutManager: NSObject {
/// Parses the text storage object into lines and builds the `lineStorage` object from those lines.
private func prepareTextLines() {
guard lineStorage.count == 0 else { return }
-#if DEBUG
+//#if DEBUG
var info = mach_timebase_info()
guard mach_timebase_info(&info) == KERN_SUCCESS else { return }
let start = mach_absolute_time()
-#endif
+//#endif
func getNextLine(startingAt location: Int) -> NSRange? {
let range = NSRange(location: location, length: 0)
@@ -94,15 +103,15 @@ class TextLayoutManager: NSObject {
))
}
- // Use a more efficient tree building algorithm than adding lines as calculated in the above loop.
+ // Use an efficient tree building algorithm rather than adding lines sequentially
lineStorage.build(from: lines, estimatedLineHeight: estimateLineHeight())
-#if DEBUG
+//#if DEBUG
let end = mach_absolute_time()
let elapsed = end - start
let nanos = elapsed * UInt64(info.numer) / UInt64(info.denom)
print("Layout Manager built in: ", TimeInterval(nanos) / TimeInterval(NSEC_PER_MSEC), "ms")
-#endif
+//#endif
}
private func estimateLineHeight() -> CGFloat {
@@ -119,10 +128,7 @@ class TextLayoutManager: NSObject {
// MARK: - Public Methods
public func estimatedHeight() -> CGFloat {
- guard let position = lineStorage.getLine(atIndex: lineStorage.length - 1) else {
- return 0.0
- }
- return position.node.height + position.height
+ lineStorage.height
}
public func estimatedWidth() -> CGFloat {
@@ -134,109 +140,202 @@ class TextLayoutManager: NSObject {
}
public func textOffsetAtPoint(_ point: CGPoint) -> Int? {
- guard let position = lineStorage.getLine(atPosition: point.y) else {
+ guard let position = lineStorage.getLine(atPosition: point.y),
+ let fragmentPosition = position.node.data.typesetter.lineFragments.getLine(
+ atPosition: point.y - position.height
+ ) else {
return nil
}
- // Find the fragment that contains the point
- var height: CGFloat = position.height
- for fragment in position.node.data.typesetter.lineFragments {
- if point.y >= height && point.y <= height + (fragment.height * lineHeightMultiplier) {
- let fragmentRange = CTLineGetStringRange(fragment.ctLine)
- if fragment.width < point.x {
- return position.offset + fragmentRange.location + fragmentRange.length
- } else {
- let fragmentIndex = CTLineGetStringIndexForPosition(
- fragment.ctLine,
- CGPoint(x: point.x, y: fragment.height/2)
- )
- return position.offset + fragmentRange.location + fragmentIndex
- }
- } else if height > point.y {
- return nil
- }
- height += fragment.height * lineHeightMultiplier
- }
+ let fragment = fragmentPosition.node.data
- return nil
+ let fragmentRange = CTLineGetStringRange(fragment.ctLine)
+ if fragment.width < point.x {
+ return position.offset + fragmentRange.location + fragmentRange.length
+ } else {
+ let fragmentIndex = CTLineGetStringIndexForPosition(
+ fragment.ctLine,
+ CGPoint(x: point.x, y: fragment.height/2)
+ )
+ return position.offset + fragmentRange.location + fragmentIndex
+ }
}
- // MARK: - Drawing
+ // MARK: - Layout
+ /// Invalidates layout for the given rect.
+ /// - Parameter rect: The rect to invalidate.
public func invalidateLayoutForRect(_ rect: NSRect) {
- // Get all lines in rect and discard their line fragment data
- for position in lineStorage.linesStartingAt(rect.minY, until: rect.maxY) {
- position.node.data.typesetter.lineFragments.removeAll(keepingCapacity: true)
+ guard let visibleRect = delegate?.visibleRect else { return }
+ // The new view IDs
+ var usedFragmentIDs = Set()
+ // The IDs that were replaced and need removing.
+ var existingFragmentIDs = Set()
+ let minY = max(max(rect.minY, 0), visibleRect.minY)
+ let maxY = min(rect.maxY, visibleRect.maxY)
+
+ for linePosition in lineStorage.linesStartingAt(minY, until: maxY) {
+ existingFragmentIDs.formUnion(Set(linePosition.node.data.typesetter.lineFragments.map(\.node.data.id)))
+
+ let lineSize = layoutLine(
+ linePosition,
+ minY: linePosition.height,
+ maxY: maxY,
+ laidOutFragmentIDs: &usedFragmentIDs
+ )
+ if lineSize.height != linePosition.node.height {
+ // If there's a height change, we need to lay out everything again and enqueue any views already used.
+ viewReuseQueue.enqueueViews(in: usedFragmentIDs.union(existingFragmentIDs))
+ layoutLines()
+ return
+ }
+ if maxLineWidth < lineSize.width {
+ maxLineWidth = lineSize.width
+ }
}
+
+ viewReuseQueue.enqueueViews(in: existingFragmentIDs)
}
+ /// Invalidates layout for the given range of text.
+ /// - Parameter range: The range of text to invalidate.
public func invalidateLayoutForRange(_ range: NSRange) {
- for position in lineStorage.linesInRange(range) {
- position.node.data.typesetter.lineFragments.removeAll(keepingCapacity: true)
+ // Determine the min/max Y value for this range and invalidate it
+ guard let minPosition = lineStorage.getLine(atIndex: range.location),
+ let maxPosition = lineStorage.getLine(atIndex: range.max) else {
+ return
}
+ invalidateLayoutForRect(
+ NSRect(
+ x: 0,
+ y: minPosition.height,
+ width: 0,
+ height: maxPosition.height + maxPosition.node.height
+ )
+ )
}
+ /// Lays out all visible lines
internal func layoutLines() {
- // Flush all line fragment views for reuse and
- }
-
-// internal func draw(inRect rect: CGRect, context: CGContext) {
-// // Get all lines in rect & draw!
-// for position in lineStorage.linesStartingAt(rect.minY, until: rect.maxY + 200) {
-// let lineSize = drawLine(
-// line: position.node.data,
-// offsetHeight: position.height,
-// minY: rect.minY,
-// context: context
-// )
-// if lineSize.height != position.node.height {
-// lineStorage.update(
-// atIndex: position.offset,
-// delta: 0,
-// deltaHeight: lineSize.height - position.node.height
-// )
-// }
-// if maxLineWidth < lineSize.width {
-// maxLineWidth = lineSize.width
-// }
-// }
-// }
-
- /// Draws a `TextLine` into the current graphics context up to a maximum y position.
- /// - Parameters:
- /// - line: The line to draw.
- /// - offsetHeight: The initial offset of the line.
- /// - minY: The minimum Y position to begin drawing from.
- /// - maxY: The maximum Y position to draw to.
- /// - Returns: The size of the rendered line.
- private func drawLine(
- line: TextLine,
- offsetHeight: CGFloat,
- minY: CGFloat,
- context: CGContext
- ) -> CGSize {
- if line.typesetter.lineFragments.isEmpty {
- line.prepareForDisplay(
- maxWidth: wrapLines
- ? delegate?.textViewportSize().width ?? .greatestFiniteMagnitude
- : .greatestFiniteMagnitude
+ guard let visibleRect = delegate?.visibleRect else { return }
+ let minY = max(visibleRect.minY - 200, 0)
+ let maxY = visibleRect.maxY + 200
+ let originalHeight = lineStorage.height
+ var usedFragmentIDs = Set()
+
+ // Layout all lines
+ for linePosition in lineStorage.linesStartingAt(minY, until: maxY) {
+ let lineSize = layoutLine(
+ linePosition,
+ minY: linePosition.height,
+ maxY: maxY,
+ laidOutFragmentIDs: &usedFragmentIDs
)
+ if lineSize.height != linePosition.node.height {
+ lineStorage.update(
+ atIndex: linePosition.offset,
+ delta: 0,
+ deltaHeight: lineSize.height - linePosition.node.height
+ )
+ }
+ if maxLineWidth < lineSize.width {
+ maxLineWidth = lineSize.width
+ }
+ }
+
+ // Enqueue any lines not used in this layout pass.
+ viewReuseQueue.enqueueViews(notInSet: usedFragmentIDs)
+
+ if originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height {
+ delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height)
}
- var height = offsetHeight
- var maxWidth: CGFloat = 0
- for lineFragment in line.typesetter.lineFragments {
- if height + (lineFragment.height * lineHeightMultiplier) >= minY {
- // The fragment is within the valid region
- context.saveGState()
- context.textMatrix = .init(scaleX: 1, y: -1)
- context.translateBy(x: 0, y: lineFragment.height)
- context.textPosition = CGPoint(x: 0, y: height)
- CTLineDraw(lineFragment.ctLine, context)
- context.restoreGState()
+ }
+
+ /// Lays out any lines that should be visible but are not laid out yet.
+ internal func updateVisibleLines() {
+ // Get all visible lines and determine if more need to be laid out vertically.
+ guard let visibleRect = delegate?.visibleRect else { return }
+ let minY = max(visibleRect.minY - 200, 0)
+ let maxY = visibleRect.maxY + 200
+ var usedFragmentIDs = Set()
+ var existingFragmentIDs = Set(viewReuseQueue.usedViews.keys)
+
+ for linePosition in lineStorage.linesStartingAt(minY, until: maxY) {
+ if linePosition.node.data.typesetter.lineFragments.isEmpty {
+ usedFragmentIDs.forEach { viewId in
+ viewReuseQueue.enqueueView(forKey: viewId)
+ }
+ layoutLines()
+ return
+ }
+ for lineFragmentPosition in linePosition
+ .node
+ .data
+ .typesetter
+ .lineFragments {
+ let lineFragment = lineFragmentPosition.node.data
+ usedFragmentIDs.insert(lineFragment.id)
+ if viewReuseQueue.usedViews[lineFragment.id] == nil {
+ layoutFragmentView(for: lineFragmentPosition, at: linePosition.height + lineFragmentPosition.height)
+ }
}
- maxWidth = max(lineFragment.width, maxWidth)
- height += lineFragment.height * lineHeightMultiplier
}
- return CGSize(width: maxWidth, height: height - offsetHeight)
+
+ viewReuseQueue.enqueueViews(in: existingFragmentIDs.subtracting(usedFragmentIDs))
+ }
+
+ /// Lays out a single text line.
+ /// - Parameters:
+ /// - position: The line position from storage to use for layout.
+ /// - minY: The minimum Y value to start at.
+ /// - maxY: The maximum Y value to end layout at.
+ /// - laidOutFragmentIDs: Updated by this method as line fragments are laid out.
+ /// - Returns: A `CGSize` representing the max width and total height of the laid out portion of the line.
+ internal func layoutLine(
+ _ position: TextLineStorage.TextLinePosition,
+ minY: CGFloat,
+ maxY: CGFloat,
+ laidOutFragmentIDs: inout Set
+ ) -> CGSize {
+ let line = position.node.data
+ line.prepareForDisplay(
+ maxWidth: wrapLines
+ ? delegate?.textViewSize().width ?? .greatestFiniteMagnitude
+ : .greatestFiniteMagnitude,
+ lineHeightMultiplier: lineHeightMultiplier
+ )
+
+ var height: CGFloat = 0
+ var width: CGFloat = 0
+
+ // TODO: Lay out only fragments in min/max Y
+ for lineFragmentPosition in line.typesetter.lineFragments {
+ let lineFragment = lineFragmentPosition.node.data
+
+ layoutFragmentView(for: lineFragmentPosition, at: minY + lineFragmentPosition.height)
+
+ width = max(width, lineFragment.width)
+ height += lineFragment.scaledHeight
+ laidOutFragmentIDs.insert(lineFragment.id)
+ }
+
+ return CGSize(width: width, height: height)
+ }
+
+ /// Lays out a line fragment view for the given line fragment at the specified y value.
+ /// - Parameters:
+ /// - lineFragment: The line fragment position to lay out a view for.
+ /// - yPos: The y value at which the line should begin.
+ private func layoutFragmentView(for lineFragment: TextLineStorage.TextLinePosition, at yPos: CGFloat) {
+ let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.node.data.id)
+ view.setLineFragment(lineFragment.node.data)
+ view.frame.origin = CGPoint(x: 0, y: yPos)
+ layoutView?.addSubview(view)
+ view.needsDisplay = true
+ }
+
+ deinit {
+ layoutView = nil
+ delegate = nil
}
}
@@ -247,6 +346,7 @@ extension TextLayoutManager: NSTextStorageDelegate {
range editedRange: NSRange,
changeInLength delta: Int
) {
+
invalidateLayoutForRange(editedRange)
delegate?.textLayoutSetNeedsDisplay()
}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
index 0fd9a80fb..dfd342f7f 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
@@ -9,9 +9,10 @@ import Foundation
import AppKit
/// Represents a displayable line of text.
-final class TextLine {
+final class TextLine: Identifiable {
typealias Attributes = [NSAttributedString.Key: Any]
+ let id: UUID = UUID()
unowned var stringRef: NSTextStorage
var range: NSRange
let typesetter: Typesetter = Typesetter()
@@ -21,10 +22,11 @@ final class TextLine {
self.range = range
}
- func prepareForDisplay(maxWidth: CGFloat) {
+ func prepareForDisplay(maxWidth: CGFloat, lineHeightMultiplier: CGFloat) {
typesetter.prepareToTypeset(
stringRef.attributedSubstring(from: range),
- maxWidth: maxWidth
+ maxWidth: maxWidth,
+ lineHeightMultiplier: lineHeightMultiplier
)
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter.swift
index 054a08644..b7113d4f4 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter.swift
@@ -11,38 +11,48 @@ import CoreText
final class Typesetter {
var typesetter: CTTypesetter?
var string: NSAttributedString!
- var lineFragments: [LineFragment] = []
+ var lineFragments = TextLineStorage()
// MARK: - Init & Prepare
init() { }
- func prepareToTypeset(_ string: NSAttributedString, maxWidth: CGFloat) {
+ func prepareToTypeset(_ string: NSAttributedString, maxWidth: CGFloat, lineHeightMultiplier: CGFloat) {
+ lineFragments.removeAll()
self.typesetter = CTTypesetterCreateWithAttributedString(string)
self.string = string
- generateLines(maxWidth: maxWidth)
+ generateLines(maxWidth: maxWidth, lineHeightMultiplier: lineHeightMultiplier)
}
// MARK: - Generate lines
- private func generateLines(maxWidth: CGFloat) {
+ private func generateLines(maxWidth: CGFloat, lineHeightMultiplier: CGFloat) {
guard let typesetter else { return }
var startIndex = 0
while startIndex < string.length {
let lineBreak = suggestLineBreak(using: typesetter, startingOffset: startIndex, constrainingWidth: maxWidth)
- lineFragments.append(typesetLine(range: NSRange(location: startIndex, length: lineBreak - startIndex)))
+ let lineFragment = typesetLine(
+ range: NSRange(location: startIndex, length: lineBreak - startIndex),
+ lineHeightMultiplier: lineHeightMultiplier
+ )
+ lineFragments.insert(
+ line: lineFragment,
+ atIndex: startIndex,
+ length: lineBreak - startIndex,
+ height: lineFragment.scaledHeight
+ )
startIndex = lineBreak
}
}
- private func typesetLine(range: NSRange) -> LineFragment {
+ private func typesetLine(range: NSRange, lineHeightMultiplier: CGFloat) -> LineFragment {
let ctLine = CTTypesetterCreateLine(typesetter!, CFRangeMake(range.location, range.length))
var ascent: CGFloat = 0
var descent: CGFloat = 0
var leading: CGFloat = 0
let width = CGFloat(CTLineGetTypographicBounds(ctLine, &ascent, &descent, &leading))
let height = ascent + descent + leading
- return LineFragment(ctLine: ctLine, width: width, height: height)
+ return LineFragment(ctLine: ctLine, width: width, height: height, lineHeightMultiplier: lineHeightMultiplier)
}
// MARK: - Line Breaks
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Iterator.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Iterator.swift
index ba6bc0803..274a84620 100644
--- a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Iterator.swift
+++ b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Iterator.swift
@@ -61,3 +61,30 @@ extension TextLineStorage {
}
}
}
+
+extension TextLineStorage: Sequence {
+ func makeIterator() -> TextLineStorageIterator {
+ TextLineStorageIterator(storage: self, currentPosition: nil)
+ }
+
+ struct TextLineStorageIterator: IteratorProtocol {
+ let storage: TextLineStorage
+ var currentPosition: TextLinePosition?
+
+ mutating func next() -> TextLinePosition? {
+ if let currentPosition {
+ guard currentPosition.offset + currentPosition.node.length < storage.length,
+ let nextPosition = storage.getLine(
+ atIndex: currentPosition.offset + currentPosition.node.length
+ ) else { return nil }
+ self.currentPosition = nextPosition
+ return self.currentPosition!
+ } else if let nextPosition = storage.getLine(atIndex: 0) {
+ self.currentPosition = nextPosition
+ return nextPosition
+ } else {
+ return nil
+ }
+ }
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift
index 4e2b8fca3..922fa563d 100644
--- a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift
+++ b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift
@@ -24,14 +24,12 @@ extension TextLineStorage {
}
}
- final class Node: Equatable {
+ final class Node: Equatable {
enum Color {
case red
case black
}
- let id: UUID = UUID()
-
// The length of the text line
var length: Int
var data: Data
@@ -69,7 +67,7 @@ extension TextLineStorage {
}
static func == (lhs: Node, rhs: Node) -> Bool {
- lhs.id == rhs.id
+ lhs.data.id == rhs.data.id
}
func minimum() -> Node? {
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
index f12c8d7c3..f2fb1e0da 100644
--- a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
+++ b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
@@ -8,7 +8,7 @@
import Foundation
/// Implements a red-black tree for efficiently editing, storing and retrieving `TextLine`s.
-final class TextLineStorage {
+final class TextLineStorage {
struct TextLinePosition {
let node: Node
let offset: Int
@@ -25,6 +25,10 @@ final class TextLineStorage {
/// The number of lines in the storage object
private(set) public var count: Int = 0
+ public var isEmpty: Bool { count == 0 }
+
+ public var height: CGFloat = 0
+
init() { }
// MARK: - Public Methods
@@ -38,6 +42,7 @@ final class TextLineStorage {
defer {
self.count += 1
self.length += length
+ self.height += height
}
let insertedNode = Node(
@@ -148,6 +153,7 @@ final class TextLineStorage {
)
}
length += delta
+ height += deltaHeight
position.node.length += delta
position.node.height += deltaHeight
metaFixup(startingAt: position.node, delta: delta, deltaHeight: deltaHeight)
@@ -166,6 +172,12 @@ final class TextLineStorage {
}
+ public func removeAll() {
+ root = nil
+ count = 0
+ length = 0
+ }
+
public func printTree() {
print(
treeString(root!) { node in
@@ -235,6 +247,7 @@ final class TextLineStorage {
}
length += node.length
+ height += node.height
node.leftSubtreeOffset = leftOffset ?? 0
node.leftSubtreeHeight = leftHeight ?? 0
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
index 572e0ed3a..abf7d599a 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
@@ -16,7 +16,7 @@ import STTextView
| |-> [TextLine] Represents a text line
| | |-> Typesetter Lays out and calculates line fragments
| | |-> [LineFragment] Represents a visual text line, stored in a line storage for long lines
- | |-> [LineFragmentLayer] Reusable fragment layers that draw a fragment in their context.
+ | |-> [LineFragmentView] Reusable line fragment view that draws a line fragment.
|
|-> TextSelectionManager (depends on LayoutManager) Maintains text selections and renders selections
| |-> [TextSelection]
@@ -36,8 +36,8 @@ class TextView: NSView, NSTextContent {
public var isEditable: Bool
public var isSelectable: Bool = true {
didSet {
- if isSelectable, let layer = self.layer {
- self.selectionManager = TextSelectionManager(layoutManager: layoutManager, parentLayer: layer)
+ if isSelectable {
+ self.selectionManager = TextSelectionManager(layoutManager: layoutManager)
} else {
self.selectionManager = nil
}
@@ -71,21 +71,6 @@ class TextView: NSView, NSTextContent {
storageDelegate: MultiStorageDelegate!
) {
self.textStorage = NSTextStorage(string: string)
- self.layoutManager = TextLayoutManager(
- textStorage: textStorage,
- typingAttributes: [
- .font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular),
- .paragraphStyle: {
- // swiftlint:disable:next force_cast
- let paragraph = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle
-// paragraph.tabStops.removeAll()
-// paragraph.defaultTabInterval = CGFloat(tabWidth) * fontCharWidth
- return paragraph
- }()
- ],
- lineHeightMultiplier: lineHeight,
- wrapLines: wrapLines
- )
self.font = font
self.lineHeight = lineHeight
@@ -94,19 +79,42 @@ class TextView: NSView, NSTextContent {
self.isEditable = isEditable
self.letterSpacing = letterSpacing
- textStorage.delegate = storageDelegate
- storageDelegate.addDelegate(layoutManager)
-
super.init(frame: .zero)
- layoutManager.delegate = self
-
wantsLayer = true
+ canDrawSubviewsIntoLayer = true
postsFrameChangedNotifications = true
postsBoundsChangedNotifications = true
autoresizingMask = [.width, .height]
- updateFrameIfNeeded()
+ self.layoutManager = TextLayoutManager(
+ textStorage: textStorage,
+ typingAttributes: [
+ .font: font,
+ .paragraphStyle: {
+ // swiftlint:disable:next force_cast
+ let paragraph = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle
+ // paragraph.tabStops.removeAll()
+ // paragraph.defaultTabInterval = CGFloat(tabWidth) * fontCharWidth
+ return paragraph
+ }()
+ ],
+ lineHeightMultiplier: lineHeight,
+ wrapLines: wrapLines,
+ textView: self, // TODO: This is an odd syntax... consider reworking this
+ delegate: self
+ )
+ textStorage.delegate = storageDelegate
+ storageDelegate.addDelegate(layoutManager)
+
+ textStorage.addAttributes(
+ [
+ .font: font
+ ],
+ range: documentRange
+ )
+
+ layoutManager.layoutLines()
if isSelectable {
self.selectionManager = TextSelectionManager(layoutManager: layoutManager)
@@ -140,7 +148,7 @@ class TextView: NSView, NSTextContent {
override func viewWillMove(toWindow newWindow: NSWindow?) {
super.viewWillMove(toWindow: newWindow)
- updateFrameIfNeeded()
+ layoutManager.layoutLines()
}
override func viewDidEndLiveResize() {
@@ -148,15 +156,6 @@ class TextView: NSView, NSTextContent {
updateFrameIfNeeded()
}
- override func layout() {
- if needsLayout {
- needsLayout = false
- CATransaction.begin()
- layoutManager.layoutLines()
- CATransaction.commit()
- }
- }
-
// MARK: - Keys
override func keyDown(with event: NSEvent) {
@@ -174,7 +173,7 @@ class TextView: NSView, NSTextContent {
override func mouseDown(with event: NSEvent) {
super.mouseDown(with: event)
-
+
}
// MARK: - Draw
@@ -213,10 +212,9 @@ class TextView: NSView, NSTextContent {
var didUpdate = false
- if frame.size.height != availableSize.height
- || (newHeight > availableSize.height && frame.size.height != newHeight) {
- frame.size.height = max(availableSize.height, newHeight + editorOverscroll)
- didUpdate = true
+ if newHeight + editorOverscroll >= availableSize.height && frame.size.height != newHeight + editorOverscroll {
+ frame.size.height = newHeight + editorOverscroll
+ // No need to update layout after height adjustment
}
if wrapLines && frame.size.width != availableSize.width {
@@ -230,6 +228,9 @@ class TextView: NSView, NSTextContent {
if didUpdate {
needsLayout = true
needsDisplay = true
+ layoutManager.layoutLines()
+ } else {
+ layoutManager.updateVisibleLines()
}
}
}
@@ -237,11 +238,15 @@ class TextView: NSView, NSTextContent {
// MARK: - TextLayoutManagerDelegate
extension TextView: TextLayoutManagerDelegate {
- func maxWidthDidChange(newWidth: CGFloat) {
+ func layoutManagerHeightDidUpdate(newHeight: CGFloat) {
+ updateFrameIfNeeded()
+ }
+
+ func layoutManagerMaxWidthDidChange(newWidth: CGFloat) {
updateFrameIfNeeded()
}
- func textViewportSize() -> CGSize {
+ func textViewSize() -> CGSize {
if let scrollView = scrollView {
var size = scrollView.contentSize
size.height -= scrollView.contentInsets.top + scrollView.contentInsets.bottom
@@ -253,5 +258,6 @@ extension TextView: TextLayoutManagerDelegate {
func textLayoutSetNeedsDisplay() {
needsDisplay = true
+ needsLayout = true
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextViewController.swift b/Sources/CodeEditTextView/TextView/TextViewController.swift
index ee0fda29c..6ec7e4f33 100644
--- a/Sources/CodeEditTextView/TextView/TextViewController.swift
+++ b/Sources/CodeEditTextView/TextView/TextViewController.swift
@@ -93,6 +93,7 @@ public class TextViewController: NSViewController {
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalRuler = true
scrollView.documentView = textView
+ scrollView.contentView.postsBoundsChangedNotifications = true
if let contentInsets {
scrollView.automaticallyAdjustsContentInsets = false
scrollView.contentInsets = contentInsets
@@ -110,12 +111,14 @@ public class TextViewController: NSViewController {
])
NotificationCenter.default.addObserver(
- forName: NSView.frameDidChangeNotification,
+ forName: NSView.boundsDidChangeNotification,
object: scrollView.contentView,
queue: .main
) { _ in
- self.textView.updateFrameIfNeeded()
+ self.textView.layoutManager.updateVisibleLines()
}
+
+ textView.updateFrameIfNeeded()
}
}
diff --git a/Sources/CodeEditTextView/TextView/MultiStorageDelegate.swift b/Sources/CodeEditTextView/TextView/Utils/MultiStorageDelegate.swift
similarity index 100%
rename from Sources/CodeEditTextView/TextView/MultiStorageDelegate.swift
rename to Sources/CodeEditTextView/TextView/Utils/MultiStorageDelegate.swift
diff --git a/Sources/CodeEditTextView/TextView/Utils/ViewReuseQueue.swift b/Sources/CodeEditTextView/TextView/Utils/ViewReuseQueue.swift
new file mode 100644
index 000000000..6f2a50dcb
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/Utils/ViewReuseQueue.swift
@@ -0,0 +1,71 @@
+//
+// ViewReuseQueue.swift
+//
+//
+// Created by Khan Winter on 8/14/23.
+//
+
+import AppKit
+import DequeModule
+
+/// Maintains a queue of views available for reuse.
+class ViewReuseQueue {
+ /// A stack of views that are not currently in use
+ var queuedViews: Deque = []
+
+ /// Maps views that are no longer queued to the keys they're queued with.
+ var usedViews: [Key: View] = [:]
+
+ /// Finds, dequeues, or creates a view for the given key.
+ ///
+ /// If the view has been dequeued, it will return the view already queued for the given key it will be returned.
+ /// If there was no view dequeued for the given key, the returned view will either be a view queued for reuse or a
+ /// new view object.
+ ///
+ /// - Parameter key: The key for the view to find.
+ /// - Returns: A view for the given key.
+ func getOrCreateView(forKey key: Key) -> View {
+ let view: View
+ if let usedView = usedViews[key] {
+ view = usedView
+ } else {
+ view = queuedViews.popFirst() ?? View()
+ view.prepareForReuse()
+ usedViews[key] = view
+ }
+ return view
+ }
+
+ /// Removes a view for the given key and enqueues it for reuse.
+ /// - Parameter key: The key for the view to reuse.
+ func enqueueView(forKey key: Key) {
+ guard let view = usedViews[key] else { return }
+ if queuedViews.count < usedViews.count / 4 {
+ queuedViews.append(view)
+ }
+ usedViews.removeValue(forKey: key)
+ view.removeFromSuperviewWithoutNeedingDisplay()
+ }
+
+ /// Enqueues all views not in the given set.
+ /// - Parameter outsideSet: The keys who's views should not be enqueued for reuse.
+ func enqueueViews(notInSet keys: Set) {
+ // Get all keys that are in "use" but not in the given set.
+ for key in Set(usedViews.keys).subtracting(keys) {
+ enqueueView(forKey: key)
+ }
+ }
+
+ /// Enqueues all views keyed by the given set.
+ /// - Parameter keys: The keys for all the views that should be enqueued.
+ func enqueueViews(in keys: Set) {
+ for key in keys {
+ enqueueView(forKey: key)
+ }
+ }
+
+ deinit {
+ usedViews.removeAll()
+ queuedViews.removeAll()
+ }
+}
From a685b9f94f070a2ddc67cfa2a446588c28090fe4 Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Wed, 16 Aug 2023 00:45:25 -0500
Subject: [PATCH 10/75] Initial cursor implementation
**Needs work**
---
.../TextLayoutManager/TextLayoutManager.swift | 41 +++++++++++--
.../TextSelectionManager/CursorLayer.swift | 21 -------
.../TextSelectionManager/CursorView.swift | 56 ++++++++++++++++++
.../TextSelectionManager.swift | 58 ++++++++++---------
.../TextView/TextView/TextView.swift | 29 ++++++----
5 files changed, 140 insertions(+), 65 deletions(-)
delete mode 100644 Sources/CodeEditTextView/TextView/TextSelectionManager/CursorLayer.swift
create mode 100644 Sources/CodeEditTextView/TextView/TextSelectionManager/CursorView.swift
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
index 16f3d4e3b..91190d218 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -68,11 +68,11 @@ class TextLayoutManager: NSObject {
/// Parses the text storage object into lines and builds the `lineStorage` object from those lines.
private func prepareTextLines() {
guard lineStorage.count == 0 else { return }
-//#if DEBUG
+#if DEBUG
var info = mach_timebase_info()
guard mach_timebase_info(&info) == KERN_SUCCESS else { return }
let start = mach_absolute_time()
-//#endif
+#endif
func getNextLine(startingAt location: Int) -> NSRange? {
let range = NSRange(location: location, length: 0)
@@ -106,12 +106,12 @@ class TextLayoutManager: NSObject {
// Use an efficient tree building algorithm rather than adding lines sequentially
lineStorage.build(from: lines, estimatedLineHeight: estimateLineHeight())
-//#if DEBUG
+#if DEBUG
let end = mach_absolute_time()
let elapsed = end - start
let nanos = elapsed * UInt64(info.numer) / UInt64(info.denom)
- print("Layout Manager built in: ", TimeInterval(nanos) / TimeInterval(NSEC_PER_MSEC), "ms")
-//#endif
+ print("Text Layout Manager built in: ", TimeInterval(nanos) / TimeInterval(NSEC_PER_MSEC), "ms")
+#endif
}
private func estimateLineHeight() -> CGFloat {
@@ -147,6 +147,7 @@ class TextLayoutManager: NSObject {
return nil
}
let fragment = fragmentPosition.node.data
+ print(CTLineGetStringRange(fragment.ctLine), fragment.width, point.x)
let fragmentRange = CTLineGetStringRange(fragment.ctLine)
if fragment.width < point.x {
@@ -160,6 +161,31 @@ class TextLayoutManager: NSObject {
}
}
+ /// Find a position for the character at a given offset.
+ /// Returns the bottom-left corner of the character.
+ /// - Parameter offset: The offset to create the rect for.
+ /// - Returns: The found rect for the given offset.
+ public func positionForOffset(_ offset: Int) -> CGPoint? {
+ guard let linePosition = lineStorage.getLine(atIndex: offset),
+ let fragmentPosition = linePosition.node.data.typesetter.lineFragments.getLine(
+ atIndex: offset - linePosition.offset
+ ) else {
+ return nil
+ }
+
+ let xPos = CTLineGetOffsetForStringIndex(
+ fragmentPosition.node.data.ctLine,
+ offset - linePosition.offset - fragmentPosition.offset,
+ nil
+ )
+
+ return CGPoint(
+ x: xPos,
+ y: linePosition.height + fragmentPosition.height
+ + (fragmentPosition.node.data.height - fragmentPosition.node.data.scaledHeight)/2
+ )
+ }
+
// MARK: - Layout
/// Invalidates layout for the given rect.
@@ -325,7 +351,10 @@ class TextLayoutManager: NSObject {
/// - Parameters:
/// - lineFragment: The line fragment position to lay out a view for.
/// - yPos: The y value at which the line should begin.
- private func layoutFragmentView(for lineFragment: TextLineStorage.TextLinePosition, at yPos: CGFloat) {
+ private func layoutFragmentView(
+ for lineFragment: TextLineStorage.TextLinePosition,
+ at yPos: CGFloat
+ ) {
let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.node.data.id)
view.setLineFragment(lineFragment.node.data)
view.frame.origin = CGPoint(x: 0, y: yPos)
diff --git a/Sources/CodeEditTextView/TextView/TextSelectionManager/CursorLayer.swift b/Sources/CodeEditTextView/TextView/TextSelectionManager/CursorLayer.swift
deleted file mode 100644
index 06926ce8c..000000000
--- a/Sources/CodeEditTextView/TextView/TextSelectionManager/CursorLayer.swift
+++ /dev/null
@@ -1,21 +0,0 @@
-//
-// CursorLayer.swift
-//
-//
-// Created by Khan Winter on 7/17/23.
-//
-
-import AppKit
-
-class CursorLayer: CALayer {
- let rect: NSRect
-
- init(rect: NSRect) {
- self.rect = rect
- super.init()
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-}
diff --git a/Sources/CodeEditTextView/TextView/TextSelectionManager/CursorView.swift b/Sources/CodeEditTextView/TextView/TextSelectionManager/CursorView.swift
new file mode 100644
index 000000000..fcf5836bc
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextSelectionManager/CursorView.swift
@@ -0,0 +1,56 @@
+//
+// CursorView.swift
+//
+//
+// Created by Khan Winter on 8/15/23.
+//
+
+import AppKit
+
+/// Animates a cursor.
+class CursorView: NSView {
+ let blinkDuration: TimeInterval?
+ let color: CGColor
+ let width: CGFloat
+
+ private var timer: Timer?
+
+ override var isFlipped: Bool {
+ true
+ }
+
+ /// Create a cursor view.
+ /// - Parameters:
+ /// - blinkDuration: The duration to blink, leave as nil to never blink.
+ /// - color: The color of the cursor.
+ /// - width: How wide the cursor should be.
+ init(
+ blinkDuration: TimeInterval? = 0.5,
+ color: CGColor = NSColor.controlAccentColor.cgColor,
+ width: CGFloat = 2.0
+ ) {
+ self.blinkDuration = blinkDuration
+ self.color = color
+ self.width = width
+
+ super.init(frame: .zero)
+
+ frame.size.width = width
+ wantsLayer = true
+ layer?.backgroundColor = color
+
+ if let blinkDuration {
+ timer = Timer.scheduledTimer(withTimeInterval: blinkDuration, repeats: true, block: { _ in
+ self.isHidden.toggle()
+ })
+ }
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ deinit {
+ timer?.invalidate()
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
index bd7930e9e..74b60757f 100644
--- a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
@@ -7,6 +7,13 @@
import AppKit
+protocol TextSelectionManagerDelegate: AnyObject {
+ var font: NSFont { get }
+ var lineHeight: CGFloat { get }
+
+ func addCursorView(_ view: NSView)
+}
+
/// Manages an array of text selections representing cursors (0-length ranges) and selections (>0-length ranges).
///
/// Draws selections using a draw method similar to the `TextLayoutManager` class, and adds
@@ -18,11 +25,11 @@ class TextSelectionManager {
class TextSelection {
var range: NSRange
- weak var layer: CALayer?
+ weak var view: CursorView?
- init(range: NSRange, layer: CALayer? = nil) {
+ init(range: NSRange, view: CursorView? = nil) {
self.range = range
- self.layer = layer
+ self.view = view
}
var isCursor: Bool {
@@ -33,42 +40,41 @@ class TextSelectionManager {
private(set) var markedText: [MarkedText] = []
private(set) var textSelections: [TextSelection] = []
private unowned var layoutManager: TextLayoutManager
+ private weak var delegate: TextSelectionManagerDelegate?
- init(layoutManager: TextLayoutManager) {
+ init(layoutManager: TextLayoutManager, delegate: TextSelectionManagerDelegate?) {
self.layoutManager = layoutManager
+ self.delegate = delegate
textSelections = [
TextSelection(range: NSRange(location: 0, length: 0))
]
-// updateSelectionLayers()
+ updateSelectionViews()
}
public func setSelectedRange(_ range: NSRange) {
+ textSelections.forEach { $0.view?.removeFromSuperview() }
textSelections = [TextSelection(range: range)]
-// updateSelectionLayers()
- layoutManager.delegate?.textLayoutSetNeedsDisplay()
+ updateSelectionViews()
}
public func setSelectedRanges(_ ranges: [NSRange]) {
+ textSelections.forEach { $0.view?.removeFromSuperview() }
textSelections = ranges.map { TextSelection(range: $0) }
-// updateSelectionLayers()
- layoutManager.delegate?.textLayoutSetNeedsDisplay()
+ updateSelectionViews()
}
- /// Updates all cursor layers.
-// private func updateSelectionLayers() {
-// for textSelection in textSelections {
-// if textSelection.isCursor {
-// textSelection.layer?.removeFromSuperlayer()
-//// let rect =
-//// let layer = CursorLayer(rect: <#T##NSRect#>)
-// }
-// }
-// }
-
- // MARK: - Draw
-
- /// Draws all visible highlight rects.
-// internal func draw(inRect rect: CGRect, context: CGContext) {
-//
-// }
+ private func updateSelectionViews() {
+ for textSelection in textSelections {
+ if textSelection.range.length == 0 {
+ textSelection.view?.removeFromSuperview()
+ let selectionView = CursorView()
+ selectionView.frame.origin = layoutManager.positionForOffset(textSelection.range.location) ?? .zero
+ selectionView.frame.size.height = (delegate?.font.lineHeight ?? 0) * (delegate?.lineHeight ?? 0)
+ delegate?.addCursorView(selectionView)
+ textSelection.view = selectionView
+ } else {
+ // TODO: Selection Highlights
+ }
+ }
+ }
}
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
index abf7d599a..1d52b122d 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
@@ -18,7 +18,7 @@ import STTextView
| | |-> [LineFragment] Represents a visual text line, stored in a line storage for long lines
| |-> [LineFragmentView] Reusable line fragment view that draws a line fragment.
|
- |-> TextSelectionManager (depends on LayoutManager) Maintains text selections and renders selections
+ |-> TextSelectionManager (depends on LayoutManager) Maintains and renders text selections
| |-> [TextSelection]
```
*/
@@ -37,7 +37,7 @@ class TextView: NSView, NSTextContent {
public var isSelectable: Bool = true {
didSet {
if isSelectable {
- self.selectionManager = TextSelectionManager(layoutManager: layoutManager)
+ self.selectionManager = TextSelectionManager(layoutManager: layoutManager, delegate: self)
} else {
self.selectionManager = nil
}
@@ -91,13 +91,6 @@ class TextView: NSView, NSTextContent {
textStorage: textStorage,
typingAttributes: [
.font: font,
- .paragraphStyle: {
- // swiftlint:disable:next force_cast
- let paragraph = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle
- // paragraph.tabStops.removeAll()
- // paragraph.defaultTabInterval = CGFloat(tabWidth) * fontCharWidth
- return paragraph
- }()
],
lineHeightMultiplier: lineHeight,
wrapLines: wrapLines,
@@ -117,7 +110,7 @@ class TextView: NSView, NSTextContent {
layoutManager.layoutLines()
if isSelectable {
- self.selectionManager = TextSelectionManager(layoutManager: layoutManager)
+ self.selectionManager = TextSelectionManager(layoutManager: layoutManager, delegate: self)
}
}
@@ -172,8 +165,12 @@ class TextView: NSView, NSTextContent {
}
override func mouseDown(with event: NSEvent) {
- super.mouseDown(with: event)
-
+ // Set cursor
+ guard let offset = layoutManager.textOffsetAtPoint(self.convert(event.locationInWindow, from: nil)) else {
+ super.mouseDown(with: event)
+ return
+ }
+ selectionManager?.setSelectedRange(NSRange(location: offset, length: 0))
}
// MARK: - Draw
@@ -261,3 +258,11 @@ extension TextView: TextLayoutManagerDelegate {
needsLayout = true
}
}
+
+// MARK: - TextSelectionManagerDelegate
+
+extension TextView: TextSelectionManagerDelegate {
+ func addCursorView(_ view: NSView) {
+ addSubview(view)
+ }
+}
From 2629ec267bd561d81597c21dd347f5a82c8d463f Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Wed, 16 Aug 2023 08:24:04 -0500
Subject: [PATCH 11/75] Fix some resizing bugs
---
.../TextLayoutManager/LineFragment.swift | 2 +-
.../TextLayoutManager/LineFragmentView.swift | 2 +-
.../TextLayoutManager/TextLayoutManager.swift | 15 +++--
.../TextView/TextLayoutManager/TextLine.swift | 2 -
.../TextLineStorage/TextLineStorage.swift | 2 +-
.../TextSelectionManager.swift | 3 +-
.../TextView/TextView/TextView.swift | 2 +
.../TextView/Utils/LineEnding.swift | 65 +++++++++++++++++++
8 files changed, 83 insertions(+), 10 deletions(-)
create mode 100644 Sources/CodeEditTextView/TextView/Utils/LineEnding.swift
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragment.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragment.swift
index 150ff6a34..d59db3e52 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragment.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragment.swift
@@ -7,7 +7,7 @@
import AppKit
-class LineFragment: Identifiable {
+final class LineFragment: Identifiable {
let id = UUID()
var ctLine: CTLine
let width: CGFloat
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentView.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentView.swift
index d3515fd66..3ab083ed5 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentView.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentView.swift
@@ -7,7 +7,7 @@
import AppKit
-class LineFragmentView: NSView {
+final class LineFragmentView: NSView {
private weak var lineFragment: LineFragment?
override var isFlipped: Bool {
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
index 91190d218..b78ad9be5 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -17,13 +17,14 @@ protocol TextLayoutManagerDelegate: AnyObject {
var visibleRect: NSRect { get }
}
-class TextLayoutManager: NSObject {
+final class TextLayoutManager: NSObject {
// MARK: - Public Config
public weak var delegate: TextLayoutManagerDelegate?
public var typingAttributes: [NSAttributedString.Key: Any]
public var lineHeightMultiplier: CGFloat
public var wrapLines: Bool
+ public var detectedLineEnding: LineEnding = .lf
// MARK: - Internal
@@ -105,6 +106,7 @@ class TextLayoutManager: NSObject {
// Use an efficient tree building algorithm rather than adding lines sequentially
lineStorage.build(from: lines, estimatedLineHeight: estimateLineHeight())
+ detectedLineEnding = LineEnding.detectLineEnding(lineStorage: lineStorage)
#if DEBUG
let end = mach_absolute_time()
@@ -147,11 +149,14 @@ class TextLayoutManager: NSObject {
return nil
}
let fragment = fragmentPosition.node.data
- print(CTLineGetStringRange(fragment.ctLine), fragment.width, point.x)
let fragmentRange = CTLineGetStringRange(fragment.ctLine)
if fragment.width < point.x {
- return position.offset + fragmentRange.location + fragmentRange.length
+ // before the eol
+ return position.offset + fragmentRange.location + fragmentRange.length - (
+ fragmentPosition.offset + fragmentPosition.node.length == position.node.data.range.max ?
+ 1 : detectedLineEnding.length
+ )
} else {
let fragmentIndex = CTLineGetStringIndexForPosition(
fragment.ctLine,
@@ -247,6 +252,7 @@ class TextLayoutManager: NSObject {
let maxY = visibleRect.maxY + 200
let originalHeight = lineStorage.height
var usedFragmentIDs = Set()
+ print("")
// Layout all lines
for linePosition in lineStorage.linesStartingAt(minY, until: maxY) {
@@ -278,12 +284,13 @@ class TextLayoutManager: NSObject {
/// Lays out any lines that should be visible but are not laid out yet.
internal func updateVisibleLines() {
+ // TODO: re-calculate layout after size change.
// Get all visible lines and determine if more need to be laid out vertically.
guard let visibleRect = delegate?.visibleRect else { return }
let minY = max(visibleRect.minY - 200, 0)
let maxY = visibleRect.maxY + 200
+ let existingFragmentIDs = Set(viewReuseQueue.usedViews.keys)
var usedFragmentIDs = Set()
- var existingFragmentIDs = Set(viewReuseQueue.usedViews.keys)
for linePosition in lineStorage.linesStartingAt(minY, until: maxY) {
if linePosition.node.data.typesetter.lineFragments.isEmpty {
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
index dfd342f7f..5bb3e0343 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
@@ -10,8 +10,6 @@ import AppKit
/// Represents a displayable line of text.
final class TextLine: Identifiable {
- typealias Attributes = [NSAttributedString.Key: Any]
-
let id: UUID = UUID()
unowned var stringRef: NSTextStorage
var range: NSRange
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
index f2fb1e0da..db0a38e82 100644
--- a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
+++ b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
@@ -346,7 +346,7 @@ private extension TextLineStorage {
/// Walk up the tree, updating any `leftSubtree` metadata.
func metaFixup(startingAt node: Node, delta: Int, deltaHeight: CGFloat) {
- guard node.parent != nil, delta > 0 else { return }
+ guard node.parent != nil else { return }
var node: Node? = node
while node != nil, node != root {
if isLeftChild(node!) {
diff --git a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
index 74b60757f..fd57731f5 100644
--- a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
@@ -63,7 +63,8 @@ class TextSelectionManager {
updateSelectionViews()
}
- private func updateSelectionViews() {
+ internal func updateSelectionViews() {
+ textSelections.forEach { $0.view?.removeFromSuperview() }
for textSelection in textSelections {
if textSelection.range.length == 0 {
textSelection.view?.removeFromSuperview()
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
index 1d52b122d..f3c61df26 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
@@ -229,6 +229,8 @@ class TextView: NSView, NSTextContent {
} else {
layoutManager.updateVisibleLines()
}
+
+ selectionManager?.updateSelectionViews()
}
}
diff --git a/Sources/CodeEditTextView/TextView/Utils/LineEnding.swift b/Sources/CodeEditTextView/TextView/Utils/LineEnding.swift
new file mode 100644
index 000000000..6cc293736
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/Utils/LineEnding.swift
@@ -0,0 +1,65 @@
+//
+// LineEnding.swift
+//
+//
+// Created by Khan Winter on 8/16/23.
+//
+
+enum LineEnding: String {
+ /// The default unix `\n` character
+ case lf = "\n"
+ /// MacOS Line ending `\r` character
+ case cr = "\r"
+ /// Windows line ending sequence `\r\n`
+ case crlf = "\r\n"
+
+ /// Initialize a line ending from a line string.
+ /// - Parameter line: The line to use
+ @inlinable
+ init?(line: String) {
+ var iterator = line.lazy.reversed().makeIterator()
+ guard var endChar = iterator.next() else { return nil }
+ if endChar == "\n" {
+ if let nextEndChar = iterator.next(), nextEndChar == "\r" {
+ self = .crlf
+ } else {
+ self = .lf
+ }
+ } else if endChar == "\r" {
+ self = .cr
+ } else {
+ return nil
+ }
+ }
+
+ /// Attempts to detect the line ending from a line storage.
+ /// - Parameter lineStorage: The line storage to enumerate.
+ /// - Returns: A line ending. Defaults to `.lf` if none could be found.
+ static func detectLineEnding(lineStorage: TextLineStorage) -> LineEnding {
+ var histogram: [LineEnding: Int] = [
+ .lf: 0,
+ .cr: 0,
+ .crlf: 0
+ ]
+ var shouldContinue = true
+ var lineIterator = lineStorage.makeIterator()
+
+ while let line = lineIterator.next()?.node.data, shouldContinue {
+ guard let lineString = line.stringRef.substring(from: line.range),
+ let lineEnding = LineEnding(line: lineString) else {
+ continue
+ }
+ histogram[lineEnding] = histogram[lineEnding]! + 1
+ // after finding 15 lines of a line ending we assume it's correct.
+ if histogram[lineEnding]! >= 15 {
+ shouldContinue = false
+ }
+ }
+
+ return histogram.max(by: { $0.value < $1.value })?.key ?? .lf
+ }
+
+ var length: Int {
+ rawValue.count
+ }
+}
From b45ec3d8c3f11b055fb8e6bb3ed07bd314acf751 Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Wed, 16 Aug 2023 10:08:00 -0500
Subject: [PATCH 12/75] Begin (completely non functional) text input
---
.../TextLayoutManager/TextLayoutManager.swift | 6 ++-
.../TextLayoutManager/Typesetter.swift | 4 ++
.../TextLineStorage+Node.swift | 6 +++
.../TextSelectionManager.swift | 5 ++
.../TextView/TextView+NSTextInput.swift | 48 ++++++++++++++-----
.../TextView/TextViewController.swift | 1 +
TODO.md | 2 +-
.../TextLayoutLineStorageTests.swift | 22 ++++-----
8 files changed, 66 insertions(+), 28 deletions(-)
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
index b78ad9be5..17779edd8 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -252,7 +252,6 @@ final class TextLayoutManager: NSObject {
let maxY = visibleRect.maxY + 200
let originalHeight = lineStorage.height
var usedFragmentIDs = Set()
- print("")
// Layout all lines
for linePosition in lineStorage.linesStartingAt(minY, until: maxY) {
@@ -370,6 +369,7 @@ final class TextLayoutManager: NSObject {
}
deinit {
+ lineStorage.removeAll()
layoutView = nil
delegate = nil
}
@@ -382,7 +382,9 @@ extension TextLayoutManager: NSTextStorageDelegate {
range editedRange: NSRange,
changeInLength delta: Int
) {
-
+ if editedMask.contains(.editedCharacters) {
+ lineStorage.update(atIndex: editedRange.location, delta: delta, deltaHeight: 0)
+ }
invalidateLayoutForRange(editedRange)
delegate?.textLayoutSetNeedsDisplay()
}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter.swift
index b7113d4f4..9b07bf40a 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter.swift
@@ -88,4 +88,8 @@ final class Typesetter {
)
return set.isSubset(of: .whitespacesWithoutNewlines) || set.isSubset(of: .punctuationCharacters)
}
+
+ deinit {
+ lineFragments.removeAll()
+ }
}
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift
index 922fa563d..53d9663cf 100644
--- a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift
+++ b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift
@@ -103,5 +103,11 @@ extension TextLineStorage {
return parent
}
}
+
+ deinit {
+ left = nil
+ right = nil
+ parent = nil
+ }
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
index fd57731f5..ed2244c16 100644
--- a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
@@ -35,6 +35,11 @@ class TextSelectionManager {
var isCursor: Bool {
range.length == 0
}
+
+ func didInsertText(length: Int) {
+ range.length = 0
+ range.location += length
+ }
}
private(set) var markedText: [MarkedText] = []
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift b/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
index b914984ec..6573a7cbf 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
@@ -26,43 +26,65 @@ import AppKit
*/
extension TextView: NSTextInputClient {
- func insertText(_ string: Any, replacementRange: NSRange) {
- print(string, replacementRange)
+ @objc public func insertText(_ string: Any, replacementRange: NSRange) {
+ guard isEditable else { return }
+ textStorage.beginEditing()
+ selectionManager?.textSelections.forEach { selection in
+ switch string {
+ case let string as NSString:
+ textStorage.replaceCharacters(in: selection.range, with: string as String)
+ selection.didInsertText(length: string.length)
+ case let string as NSAttributedString:
+ textStorage.replaceCharacters(in: selection.range, with: string)
+ selection.didInsertText(length: string.length)
+ default:
+ assertionFailure("\(#function) called with invalid string type. Expected String or NSAttributedString.")
+ }
+ }
+ textStorage.endEditing()
+ selectionManager?.updateSelectionViews()
+ print(selectionManager!.textSelections.map { $0.range })
}
- func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
+ @objc public func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
}
- func unmarkText() {
+ @objc public func unmarkText() {
}
- func selectedRange() -> NSRange {
- .zero
+ @objc public func selectedRange() -> NSRange {
+ return selectionManager?.textSelections.first?.range ?? NSRange.zero
}
- func markedRange() -> NSRange {
+ @objc public func markedRange() -> NSRange {
.zero
}
- func hasMarkedText() -> Bool {
+ @objc public func hasMarkedText() -> Bool {
false
}
- func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? {
- nil
+ @objc public func attributedSubstring(
+ forProposedRange range: NSRange,
+ actualRange: NSRangePointer?
+ ) -> NSAttributedString? {
+ let realRange = (textStorage.string as NSString).rangeOfComposedCharacterSequences(for: range)
+ actualRange?.pointee = realRange
+ print(realRange)
+ return textStorage.attributedSubstring(from: realRange)
}
- func validAttributesForMarkedText() -> [NSAttributedString.Key] {
+ @objc public func validAttributesForMarkedText() -> [NSAttributedString.Key] {
[]
}
- func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect {
+ @objc public func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect {
.zero
}
- func characterIndex(for point: NSPoint) -> Int {
+ @objc public func characterIndex(for point: NSPoint) -> Int {
layoutManager.textOffsetAtPoint(point) ?? NSNotFound
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextViewController.swift b/Sources/CodeEditTextView/TextView/TextViewController.swift
index 6ec7e4f33..cab9af0e1 100644
--- a/Sources/CodeEditTextView/TextView/TextViewController.swift
+++ b/Sources/CodeEditTextView/TextView/TextViewController.swift
@@ -116,6 +116,7 @@ public class TextViewController: NSViewController {
queue: .main
) { _ in
self.textView.layoutManager.updateVisibleLines()
+ self.textView.inputContext?.invalidateCharacterCoordinates()
}
textView.updateFrameIfNeeded()
diff --git a/TODO.md b/TODO.md
index cb33cebdc..a4d21161b 100644
--- a/TODO.md
+++ b/TODO.md
@@ -6,7 +6,7 @@
- [X] wrap text
- [X] resize correctly
- [x] syntax highlighting
-- [] cursor
+- [x] cursor
- [] edit text
- [] isEditable
- [] tab widths & indents
diff --git a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
index 7f0a0f688..2d103f720 100644
--- a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
+++ b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
@@ -3,8 +3,8 @@ import XCTest
final class TextLayoutLineStorageTests: XCTestCase {
func test_insert() {
- let tree = TextLineStorage()
- let stringRef = NSString()
+ let tree = TextLineStorage()
+ let stringRef = NSTextStorage(string: "")
var sum = 0
for i in 0..<20 {
tree.insert(
@@ -15,13 +15,13 @@ final class TextLayoutLineStorageTests: XCTestCase {
)
sum += i + 1
}
- XCTAssert(tree.getLine(atIndex: 2)?.0.length == 2, "Found line incorrect, expected length of 2.")
- XCTAssert(tree.getLine(atIndex: 36)?.0.length == 9, "Found line incorrect, expected length of 9.")
+ XCTAssert(tree.getLine(atIndex: 2)?.node.length == 2, "Found line incorrect, expected length of 2.")
+ XCTAssert(tree.getLine(atIndex: 36)?.node.length == 9, "Found line incorrect, expected length of 9.")
}
func test_update() {
- let tree = TextLineStorage()
- let stringRef = NSString()
+ let tree = TextLineStorage()
+ let stringRef = NSTextStorage(string: "")
var sum = 0
for i in 0..<20 {
tree.insert(
@@ -32,13 +32,11 @@ final class TextLayoutLineStorageTests: XCTestCase {
)
sum += i + 1
}
-
-
}
func test_insertPerformance() {
- let tree = TextLineStorage()
- let stringRef = NSString()
+ let tree = TextLineStorage()
+ let stringRef = NSTextStorage(string: "")
measure {
for i in 0..<250_000 {
tree.insert(line: .init(stringRef: stringRef, range: .init(location: 0, length: 0)), atIndex: i, length: 1, height: 0.0)
@@ -47,8 +45,8 @@ final class TextLayoutLineStorageTests: XCTestCase {
}
func test_insertFastPerformance() {
- let tree = TextLineStorage()
- let stringRef = NSString()
+ let tree = TextLineStorage()
+ let stringRef = NSTextStorage(string: "")
measure {
var lines: [(TextLine, Int)] = []
for i in 0..<250_000 {
From 2a292f024c11b6bedd01284cfcc68f8b6273d73f Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Sun, 20 Aug 2023 11:58:19 -0500
Subject: [PATCH 13/75] Refactor TextLineStorage, Add Insert Editing
---
.../TextLayoutManager/TextLayoutManager.swift | 102 ++++++++++--------
.../TextView/TextLayoutManager/TextLine.swift | 8 +-
.../TextLineStorage+Iterator.swift | 48 ++++++---
.../TextLineStorage/TextLineStorage.swift | 83 +++++++++++---
.../TextView/TextView+NSTextInput.swift | 2 -
.../TextView/TextView/TextView.swift | 2 +-
.../TextView/TextViewController.swift | 6 +-
.../TextView/Utils/LineEnding.swift | 6 +-
TODO.md | 17 +--
.../TextLayoutLineStorageTests.swift | 28 +++--
10 files changed, 201 insertions(+), 101 deletions(-)
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
index 17779edd8..dc311aeec 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -91,16 +91,16 @@ final class TextLayoutManager: NSObject {
var lines: [(TextLine, Int)] = []
while let range = getNextLine(startingAt: index) {
lines.append((
- TextLine(stringRef: textStorage, range: NSRange(location: index, length: NSMaxRange(range) - index)),
- NSMaxRange(range) - index
+ TextLine(stringRef: textStorage),
+ range.max - index
))
index = NSMaxRange(range)
}
// Create the last line
if textStorage.length - index > 0 {
lines.append((
- TextLine(stringRef: textStorage, range: NSRange(location: index, length: textStorage.length - index)),
- index
+ TextLine(stringRef: textStorage),
+ textStorage.length - index
))
}
@@ -137,32 +137,34 @@ final class TextLayoutManager: NSObject {
maxLineWidth
}
- public func textLineForPosition(_ posY: CGFloat) -> TextLine? {
- lineStorage.getLine(atPosition: posY)?.node.data
+ public func textLineForPosition(_ posY: CGFloat) -> TextLineStorage.TextLinePosition? {
+ lineStorage.getLine(atPosition: posY)
}
public func textOffsetAtPoint(_ point: CGPoint) -> Int? {
guard let position = lineStorage.getLine(atPosition: point.y),
- let fragmentPosition = position.node.data.typesetter.lineFragments.getLine(
- atPosition: point.y - position.height
+ let fragmentPosition = position.data.typesetter.lineFragments.getLine(
+ atPosition: point.y - position.yPos
) else {
return nil
}
- let fragment = fragmentPosition.node.data
+ let fragment = fragmentPosition.data
- let fragmentRange = CTLineGetStringRange(fragment.ctLine)
if fragment.width < point.x {
- // before the eol
- return position.offset + fragmentRange.location + fragmentRange.length - (
- fragmentPosition.offset + fragmentPosition.node.length == position.node.data.range.max ?
+ let fragmentRange = CTLineGetStringRange(fragment.ctLine)
+ // Return eol
+ return position.range.location + fragmentRange.location + fragmentRange.length - (
+ // Before the eol character (insertion point is before the eol)
+ fragmentPosition.range.max == position.range.max ?
1 : detectedLineEnding.length
)
} else {
+ // Somewhere in the fragment
let fragmentIndex = CTLineGetStringIndexForPosition(
fragment.ctLine,
CGPoint(x: point.x, y: fragment.height/2)
)
- return position.offset + fragmentRange.location + fragmentIndex
+ return position.range.location + fragmentIndex
}
}
@@ -172,26 +174,26 @@ final class TextLayoutManager: NSObject {
/// - Returns: The found rect for the given offset.
public func positionForOffset(_ offset: Int) -> CGPoint? {
guard let linePosition = lineStorage.getLine(atIndex: offset),
- let fragmentPosition = linePosition.node.data.typesetter.lineFragments.getLine(
- atIndex: offset - linePosition.offset
+ let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine(
+ atIndex: offset - linePosition.range.location
) else {
return nil
}
let xPos = CTLineGetOffsetForStringIndex(
- fragmentPosition.node.data.ctLine,
- offset - linePosition.offset - fragmentPosition.offset,
+ fragmentPosition.data.ctLine,
+ offset - linePosition.range.location,
nil
)
return CGPoint(
x: xPos,
- y: linePosition.height + fragmentPosition.height
- + (fragmentPosition.node.data.height - fragmentPosition.node.data.scaledHeight)/2
+ y: linePosition.yPos + fragmentPosition.yPos
+ + (fragmentPosition.data.height - fragmentPosition.data.scaledHeight)/2
)
}
- // MARK: - Layout
+ // MARK: - Invalidation
/// Invalidates layout for the given rect.
/// - Parameter rect: The rect to invalidate.
@@ -205,15 +207,15 @@ final class TextLayoutManager: NSObject {
let maxY = min(rect.maxY, visibleRect.maxY)
for linePosition in lineStorage.linesStartingAt(minY, until: maxY) {
- existingFragmentIDs.formUnion(Set(linePosition.node.data.typesetter.lineFragments.map(\.node.data.id)))
+ existingFragmentIDs.formUnion(Set(linePosition.data.typesetter.lineFragments.map(\.data.id)))
let lineSize = layoutLine(
linePosition,
- minY: linePosition.height,
+ minY: linePosition.yPos,
maxY: maxY,
laidOutFragmentIDs: &usedFragmentIDs
)
- if lineSize.height != linePosition.node.height {
+ if lineSize.height != linePosition.height {
// If there's a height change, we need to lay out everything again and enqueue any views already used.
viewReuseQueue.enqueueViews(in: usedFragmentIDs.union(existingFragmentIDs))
layoutLines()
@@ -238,13 +240,15 @@ final class TextLayoutManager: NSObject {
invalidateLayoutForRect(
NSRect(
x: 0,
- y: minPosition.height,
+ y: minPosition.yPos,
width: 0,
- height: maxPosition.height + maxPosition.node.height
+ height: maxPosition.yPos + maxPosition.height
)
)
}
+ // MARK: - Layout
+
/// Lays out all visible lines
internal func layoutLines() {
guard let visibleRect = delegate?.visibleRect else { return }
@@ -257,15 +261,15 @@ final class TextLayoutManager: NSObject {
for linePosition in lineStorage.linesStartingAt(minY, until: maxY) {
let lineSize = layoutLine(
linePosition,
- minY: linePosition.height,
+ minY: linePosition.yPos,
maxY: maxY,
laidOutFragmentIDs: &usedFragmentIDs
)
- if lineSize.height != linePosition.node.height {
+ if lineSize.height != linePosition.height {
lineStorage.update(
- atIndex: linePosition.offset,
+ atIndex: linePosition.range.location,
delta: 0,
- deltaHeight: lineSize.height - linePosition.node.height
+ deltaHeight: lineSize.height - linePosition.height
)
}
if maxLineWidth < lineSize.width {
@@ -282,7 +286,9 @@ final class TextLayoutManager: NSObject {
}
/// Lays out any lines that should be visible but are not laid out yet.
- internal func updateVisibleLines() {
+ /// - Parameter delta: If used a scroll view, the delta between the last y position and the current y position.
+ /// Used to correctly update the view's height without jumping down in the active scroll.
+ internal func updateVisibleLines(delta: CGFloat?) {
// TODO: re-calculate layout after size change.
// Get all visible lines and determine if more need to be laid out vertically.
guard let visibleRect = delegate?.visibleRect else { return }
@@ -292,22 +298,21 @@ final class TextLayoutManager: NSObject {
var usedFragmentIDs = Set()
for linePosition in lineStorage.linesStartingAt(minY, until: maxY) {
- if linePosition.node.data.typesetter.lineFragments.isEmpty {
+ if linePosition.data.typesetter.lineFragments.isEmpty {
usedFragmentIDs.forEach { viewId in
viewReuseQueue.enqueueView(forKey: viewId)
}
layoutLines()
return
}
- for lineFragmentPosition in linePosition
- .node
- .data
- .typesetter
- .lineFragments {
- let lineFragment = lineFragmentPosition.node.data
+ for lineFragmentPosition in linePosition.data.typesetter.lineFragments {
+ let lineFragment = lineFragmentPosition.data
usedFragmentIDs.insert(lineFragment.id)
if viewReuseQueue.usedViews[lineFragment.id] == nil {
- layoutFragmentView(for: lineFragmentPosition, at: linePosition.height + lineFragmentPosition.height)
+ layoutFragmentView(
+ for: lineFragmentPosition,
+ at: linePosition.yPos + lineFragmentPosition.height
+ )
}
}
}
@@ -328,12 +333,13 @@ final class TextLayoutManager: NSObject {
maxY: CGFloat,
laidOutFragmentIDs: inout Set
) -> CGSize {
- let line = position.node.data
+ let line = position.data
line.prepareForDisplay(
maxWidth: wrapLines
? delegate?.textViewSize().width ?? .greatestFiniteMagnitude
: .greatestFiniteMagnitude,
- lineHeightMultiplier: lineHeightMultiplier
+ lineHeightMultiplier: lineHeightMultiplier,
+ range: position.range
)
var height: CGFloat = 0
@@ -341,9 +347,9 @@ final class TextLayoutManager: NSObject {
// TODO: Lay out only fragments in min/max Y
for lineFragmentPosition in line.typesetter.lineFragments {
- let lineFragment = lineFragmentPosition.node.data
+ let lineFragment = lineFragmentPosition.data
- layoutFragmentView(for: lineFragmentPosition, at: minY + lineFragmentPosition.height)
+ layoutFragmentView(for: lineFragmentPosition, at: minY + lineFragmentPosition.yPos)
width = max(width, lineFragment.width)
height += lineFragment.scaledHeight
@@ -361,8 +367,8 @@ final class TextLayoutManager: NSObject {
for lineFragment: TextLineStorage.TextLinePosition,
at yPos: CGFloat
) {
- let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.node.data.id)
- view.setLineFragment(lineFragment.node.data)
+ let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id)
+ view.setLineFragment(lineFragment.data)
view.frame.origin = CGPoint(x: 0, y: yPos)
layoutView?.addSubview(view)
view.needsDisplay = true
@@ -384,8 +390,10 @@ extension TextLayoutManager: NSTextStorageDelegate {
) {
if editedMask.contains(.editedCharacters) {
lineStorage.update(atIndex: editedRange.location, delta: delta, deltaHeight: 0)
+ layoutLines()
+ } else {
+ invalidateLayoutForRange(editedRange)
+ delegate?.textLayoutSetNeedsDisplay()
}
- invalidateLayoutForRange(editedRange)
- delegate?.textLayoutSetNeedsDisplay()
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
index 5bb3e0343..5e298a843 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
@@ -12,15 +12,15 @@ import AppKit
final class TextLine: Identifiable {
let id: UUID = UUID()
unowned var stringRef: NSTextStorage
- var range: NSRange
+ var maxWidth: CGFloat?
let typesetter: Typesetter = Typesetter()
- init(stringRef: NSTextStorage, range: NSRange) {
+ init(stringRef: NSTextStorage) {
self.stringRef = stringRef
- self.range = range
}
- func prepareForDisplay(maxWidth: CGFloat, lineHeightMultiplier: CGFloat) {
+ func prepareForDisplay(maxWidth: CGFloat, lineHeightMultiplier: CGFloat, range: NSRange) {
+ self.maxWidth = maxWidth
typesetter.prepareToTypeset(
stringRef.attributedSubstring(from: range),
maxWidth: maxWidth,
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Iterator.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Iterator.swift
index 274a84620..a676e519c 100644
--- a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Iterator.swift
+++ b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Iterator.swift
@@ -17,16 +17,23 @@ extension TextLineStorage {
}
struct TextLineStorageYIterator: Sequence, IteratorProtocol {
- let storage: TextLineStorage
- let minY: CGFloat
- let maxY: CGFloat
- var currentPosition: TextLinePosition?
+ private let storage: TextLineStorage
+ private let minY: CGFloat
+ private let maxY: CGFloat
+ private var currentPosition: TextLinePosition?
+
+ init(storage: TextLineStorage, minY: CGFloat, maxY: CGFloat, currentPosition: TextLinePosition? = nil) {
+ self.storage = storage
+ self.minY = minY
+ self.maxY = maxY
+ self.currentPosition = currentPosition
+ }
mutating func next() -> TextLinePosition? {
if let currentPosition {
- guard currentPosition.height < maxY,
+ guard currentPosition.yPos < maxY,
let nextPosition = storage.getLine(
- atIndex: currentPosition.offset + currentPosition.node.length
+ atIndex: currentPosition.range.max
) else { return nil }
self.currentPosition = nextPosition
return self.currentPosition!
@@ -40,15 +47,21 @@ extension TextLineStorage {
}
struct TextLineStorageRangeIterator: Sequence, IteratorProtocol {
- let storage: TextLineStorage
- let range: NSRange
- var currentPosition: TextLinePosition?
+ private let storage: TextLineStorage
+ private let range: NSRange
+ private var currentPosition: TextLinePosition?
+
+ init(storage: TextLineStorage, range: NSRange, currentPosition: TextLinePosition? = nil) {
+ self.storage = storage
+ self.range = range
+ self.currentPosition = currentPosition
+ }
mutating func next() -> TextLinePosition? {
if let currentPosition {
- guard currentPosition.offset + currentPosition.node.length < NSMaxRange(range),
+ guard currentPosition.range.max < range.max,
let nextPosition = storage.getLine(
- atIndex: currentPosition.offset + currentPosition.node.length
+ atIndex: currentPosition.range.max
) else { return nil }
self.currentPosition = nextPosition
return self.currentPosition!
@@ -68,14 +81,19 @@ extension TextLineStorage: Sequence {
}
struct TextLineStorageIterator: IteratorProtocol {
- let storage: TextLineStorage
- var currentPosition: TextLinePosition?
+ private let storage: TextLineStorage
+ private var currentPosition: TextLinePosition?
+
+ init(storage: TextLineStorage, currentPosition: TextLinePosition? = nil) {
+ self.storage = storage
+ self.currentPosition = currentPosition
+ }
mutating func next() -> TextLinePosition? {
if let currentPosition {
- guard currentPosition.offset + currentPosition.node.length < storage.length,
+ guard currentPosition.range.max < storage.length,
let nextPosition = storage.getLine(
- atIndex: currentPosition.offset + currentPosition.node.length
+ atIndex: currentPosition.range.max
) else { return nil }
self.currentPosition = nextPosition
return self.currentPosition!
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
index db0a38e82..f3dc5817f 100644
--- a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
+++ b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
@@ -10,11 +10,39 @@ import Foundation
/// Implements a red-black tree for efficiently editing, storing and retrieving `TextLine`s.
final class TextLineStorage {
struct TextLinePosition {
- let node: Node
- let offset: Int
+ init(data: Data, range: NSRange, yPos: CGFloat, height: CGFloat) {
+ self.data = data
+ self.range = range
+ self.yPos = yPos
+ self.height = height
+ }
+
+ init(position: NodePosition) {
+ self.data = position.node.data
+ self.range = NSRange(location: position.textPos, length: position.node.length)
+ self.yPos = position.yPos
+ self.height = position.node.height
+ }
+
+ /// The data stored at the position
+ let data: Data
+ /// The range represented by the data
+ let range: NSRange
+ /// The y position of the data, on a top down y axis
+ let yPos: CGFloat
+ /// The height of the stored data
let height: CGFloat
}
+ internal struct NodePosition {
+ /// The node storing information and the data stored at the position.
+ let node: Node
+ /// The y position of the data, on a top down y axis
+ let yPos: CGFloat
+ /// The location of the node in the document
+ let textPos: Int
+ }
+
#if DEBUG
var root: Node?
#else
@@ -29,6 +57,24 @@ final class TextLineStorage {
public var height: CGFloat = 0
+ // TODO: Cache this value & update on tree update
+ var first: TextLinePosition? {
+ guard length > 0,
+ let position = search(for: length - 1) else {
+ return nil
+ }
+ return TextLinePosition(position: position)
+ }
+
+ // TODO: Cache this value & update on tree update
+ var last: TextLinePosition? {
+ guard length > 0 else { return nil }
+ guard let position = search(for: length - 1) else {
+ return nil
+ }
+ return TextLinePosition(position: position)
+ }
+
init() { }
// MARK: - Public Methods
@@ -92,7 +138,8 @@ final class TextLineStorage {
/// - Parameter index: The index to fetch for.
/// - Returns: A text line object representing a generated line object and the offset in the document of the line.
public func getLine(atIndex index: Int) -> TextLinePosition? {
- return search(for: index)
+ guard let nodePosition = search(for: index) else { return nil }
+ return TextLinePosition(position: nodePosition)
}
/// Fetches a line for the given `y` value.
@@ -103,19 +150,24 @@ final class TextLineStorage {
public func getLine(atPosition posY: CGFloat) -> TextLinePosition? {
var currentNode = root
var currentOffset: Int = root?.leftSubtreeOffset ?? 0
- var currentHeight: CGFloat = root?.leftSubtreeHeight ?? 0
+ var currentYPosition: CGFloat = root?.leftSubtreeHeight ?? 0
while let node = currentNode {
// If index is in the range [currentOffset..= currentHeight && posY < currentHeight + node.height {
- return TextLinePosition(node: node, offset: currentOffset, height: currentHeight)
- } else if currentHeight > posY {
+ if posY >= currentYPosition && posY < currentYPosition + node.height {
+ return TextLinePosition(
+ data: node.data,
+ range: NSRange(location: currentOffset, length: node.length),
+ yPos: currentYPosition,
+ height: node.height
+ )
+ } else if currentYPosition > posY {
currentNode = node.left
currentOffset = (currentOffset - node.leftSubtreeOffset) + (node.left?.leftSubtreeOffset ?? 0)
- currentHeight = (currentHeight - node.leftSubtreeHeight) + (node.left?.leftSubtreeHeight ?? 0)
+ currentYPosition = (currentYPosition - node.leftSubtreeHeight) + (node.left?.leftSubtreeHeight ?? 0)
} else if node.leftSubtreeHeight < posY {
currentNode = node.right
currentOffset += node.length + (node.right?.leftSubtreeOffset ?? 0)
- currentHeight += node.height + (node.right?.leftSubtreeHeight ?? 0)
+ currentYPosition += node.height + (node.right?.leftSubtreeHeight ?? 0)
} else {
currentNode = nil
}
@@ -148,7 +200,7 @@ final class TextLineStorage {
}
if delta < 0 {
assert(
- index - position.offset > delta,
+ index - position.textPos > delta,
"Delta too large. Deleting \(-delta) from line at position \(index) extends beyond the line's range."
)
}
@@ -182,6 +234,7 @@ final class TextLineStorage {
print(
treeString(root!) { node in
(
+ // swiftlint:disable:next line_length
"\(node.length)[\(node.leftSubtreeOffset)\(node.color == .red ? "R" : "B")][\(node.height), \(node.leftSubtreeHeight)]",
node.left,
node.right
@@ -265,22 +318,22 @@ private extension TextLineStorage {
/// Searches for the given index. Returns a node and offset if found.
/// - Parameter index: The index to look for in the document.
/// - Returns: A tuple containing a node if it was found, and the offset of the node in the document.
- func search(for index: Int) -> TextLinePosition? {
+ func search(for index: Int) -> NodePosition? {
var currentNode = root
var currentOffset: Int = root?.leftSubtreeOffset ?? 0
- var currentHeight: CGFloat = root?.leftSubtreeHeight ?? 0
+ var currentYPosition: CGFloat = root?.leftSubtreeHeight ?? 0
while let node = currentNode {
// If index is in the range [currentOffset..= currentOffset && index < currentOffset + node.length {
- return TextLinePosition(node: node, offset: currentOffset, height: currentHeight)
+ return NodePosition(node: node, yPos: currentYPosition, textPos: currentOffset)
} else if currentOffset > index {
currentNode = node.left
currentOffset = (currentOffset - node.leftSubtreeOffset) + (node.left?.leftSubtreeOffset ?? 0)
- currentHeight = (currentHeight - node.leftSubtreeHeight) + (node.left?.leftSubtreeHeight ?? 0)
+ currentYPosition = (currentYPosition - node.leftSubtreeHeight) + (node.left?.leftSubtreeHeight ?? 0)
} else if node.leftSubtreeOffset < index {
currentNode = node.right
currentOffset += node.length + (node.right?.leftSubtreeOffset ?? 0)
- currentHeight += node.height + (node.right?.leftSubtreeHeight ?? 0)
+ currentYPosition += node.height + (node.right?.leftSubtreeHeight ?? 0)
} else {
currentNode = nil
}
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift b/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
index 6573a7cbf..ac98b9671 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
@@ -43,7 +43,6 @@ extension TextView: NSTextInputClient {
}
textStorage.endEditing()
selectionManager?.updateSelectionViews()
- print(selectionManager!.textSelections.map { $0.range })
}
@objc public func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
@@ -72,7 +71,6 @@ extension TextView: NSTextInputClient {
) -> NSAttributedString? {
let realRange = (textStorage.string as NSString).rangeOfComposedCharacterSequences(for: range)
actualRange?.pointee = realRange
- print(realRange)
return textStorage.attributedSubstring(from: realRange)
}
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
index f3c61df26..a5277ca18 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
@@ -227,7 +227,7 @@ class TextView: NSView, NSTextContent {
needsDisplay = true
layoutManager.layoutLines()
} else {
- layoutManager.updateVisibleLines()
+ layoutManager.updateVisibleLines(delta: nil)
}
selectionManager?.updateSelectionViews()
diff --git a/Sources/CodeEditTextView/TextView/TextViewController.swift b/Sources/CodeEditTextView/TextView/TextViewController.swift
index cab9af0e1..d37147c5d 100644
--- a/Sources/CodeEditTextView/TextView/TextViewController.swift
+++ b/Sources/CodeEditTextView/TextView/TextViewController.swift
@@ -31,6 +31,7 @@ public class TextViewController: NSViewController {
private var storageDelegate: MultiStorageDelegate!
private var highlighter: Highlighter?
+ private var lastScrollPosition: CGFloat = 0
init(
string: Binding,
@@ -115,7 +116,10 @@ public class TextViewController: NSViewController {
object: scrollView.contentView,
queue: .main
) { _ in
- self.textView.layoutManager.updateVisibleLines()
+ self.lastScrollPosition = self.scrollView.documentVisibleRect.origin.y
+ self.textView.layoutManager.updateVisibleLines(
+ delta: self.scrollView.documentVisibleRect.origin.y - self.lastScrollPosition
+ )
self.textView.inputContext?.invalidateCharacterCoordinates()
}
diff --git a/Sources/CodeEditTextView/TextView/Utils/LineEnding.swift b/Sources/CodeEditTextView/TextView/Utils/LineEnding.swift
index 6cc293736..fcee4e702 100644
--- a/Sources/CodeEditTextView/TextView/Utils/LineEnding.swift
+++ b/Sources/CodeEditTextView/TextView/Utils/LineEnding.swift
@@ -18,7 +18,7 @@ enum LineEnding: String {
@inlinable
init?(line: String) {
var iterator = line.lazy.reversed().makeIterator()
- guard var endChar = iterator.next() else { return nil }
+ guard let endChar = iterator.next() else { return nil }
if endChar == "\n" {
if let nextEndChar = iterator.next(), nextEndChar == "\r" {
self = .crlf
@@ -44,8 +44,8 @@ enum LineEnding: String {
var shouldContinue = true
var lineIterator = lineStorage.makeIterator()
- while let line = lineIterator.next()?.node.data, shouldContinue {
- guard let lineString = line.stringRef.substring(from: line.range),
+ while let line = lineIterator.next(), shouldContinue {
+ guard let lineString = line.data.stringRef.substring(from: line.range),
let lineEnding = LineEnding(line: lineString) else {
continue
}
diff --git a/TODO.md b/TODO.md
index a4d21161b..e6d244557 100644
--- a/TODO.md
+++ b/TODO.md
@@ -3,14 +3,21 @@
- [X] load file
- [X] render text
- [X] scroll
+ - [] When scrolling, if width changed & scrolling up, batch layout a couple hundred lines (test for correct # for
+ speed), update height and scroll offset to keep user in same spot.
- [X] wrap text
- [X] resize correctly
- [x] syntax highlighting
- [x] cursor
- [] edit text
- - [] isEditable
+ - [x] isEditable
+ - [x] Insert
+ - [] Delete
+ - [] Copy/paste
+- [] select text
+- [] multiple cursors (+ edit)
- [] tab widths & indents
-- [] update parameters in real time
+- [] paramater updating
- [] tab & indent options
- [] kern
- [] theme
@@ -21,13 +28,11 @@
- [] highlight provider
- [] content insets
- [] isEditable
+ - [] isSelectable
- [] language
-- [] select text
-- [] multiple selection
-- [] copy/paste
- [] undo/redo
- [] sync system appearance
-- [] update cursor position
+- [x] update cursor position
- [] update text (from outside)
- [] highlight brackets
- [] textformation integration
diff --git a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
index 2d103f720..0b74cff11 100644
--- a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
+++ b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
@@ -8,15 +8,15 @@ final class TextLayoutLineStorageTests: XCTestCase {
var sum = 0
for i in 0..<20 {
tree.insert(
- line: .init(stringRef: stringRef, range: .init(location: 0, length: 0)),
+ line: .init(stringRef: stringRef),
atIndex: sum,
length: i + 1,
height: 1.0
)
sum += i + 1
}
- XCTAssert(tree.getLine(atIndex: 2)?.node.length == 2, "Found line incorrect, expected length of 2.")
- XCTAssert(tree.getLine(atIndex: 36)?.node.length == 9, "Found line incorrect, expected length of 9.")
+ XCTAssert(tree.getLine(atIndex: 2)?.range.length == 2, "Found line incorrect, expected length of 2.")
+ XCTAssert(tree.getLine(atIndex: 36)?.range.length == 9, "Found line incorrect, expected length of 9.")
}
func test_update() {
@@ -25,21 +25,35 @@ final class TextLayoutLineStorageTests: XCTestCase {
var sum = 0
for i in 0..<20 {
tree.insert(
- line: .init(stringRef: stringRef, range: .init(location: 0, length: 0)),
+ line: .init(stringRef: stringRef),
atIndex: sum,
length: i + 1,
height: 1.0
)
sum += i + 1
}
+ tree.update(atIndex: 7, delta: 1, deltaHeight: 0)
+ // TODO:
+// XCTAssert(tree.getLine(atIndex: 7)?.range.length == 8, "")
}
func test_insertPerformance() {
let tree = TextLineStorage()
let stringRef = NSTextStorage(string: "")
+ var lines: [(TextLine, Int)] = []
+ for i in 0..<250_000 {
+ lines.append((
+ TextLine(stringRef: stringRef),
+ i + 1
+ ))
+ }
+ tree.build(from: lines, estimatedLineHeight: 1.0)
+ // Measure time when inserting randomly into an already built tree.
measure {
- for i in 0..<250_000 {
- tree.insert(line: .init(stringRef: stringRef, range: .init(location: 0, length: 0)), atIndex: i, length: 1, height: 0.0)
+ for _ in 0..<100_000 {
+ tree.insert(
+ line: .init(stringRef: stringRef), atIndex: Int.random(in: 0..
Date: Mon, 21 Aug 2023 19:23:53 -0500
Subject: [PATCH 14/75] Final layout refactor, resizable, copy/paste
---
.../STTextViewController+Lifecycle.swift | 2 +-
.../STTextView+TextInterface.swift | 4 +-
.../TextLayoutManager/TextLayoutManager.swift | 140 ++++++------------
.../TextView/TextLayoutManager/TextLine.swift | 18 ++-
.../TextLineStorage/TextLineStorage.swift | 2 +-
.../TextSelectionManager.swift | 3 +-
.../TextView/TextView+CopyPaste.swift | 32 ++++
.../TextView/TextView/TextView+Menu.swift | 21 +++
.../TextView/TextView+NSTextInput.swift | 13 +-
.../TextView/TextView/TextView+UndoRedo.swift | 27 ++++
.../TextView/TextView/TextView.swift | 47 +++++-
.../TextView/TextViewController.swift | 16 +-
.../Utils}/CEUndoManager.swift | 29 +++-
.../TextView/Utils/LineEnding.swift | 2 +-
TODO.md | 5 +-
.../TextLayoutLineStorageTests.swift | 19 +++
16 files changed, 252 insertions(+), 128 deletions(-)
create mode 100644 Sources/CodeEditTextView/TextView/TextView/TextView+CopyPaste.swift
create mode 100644 Sources/CodeEditTextView/TextView/TextView/TextView+Menu.swift
create mode 100644 Sources/CodeEditTextView/TextView/TextView/TextView+UndoRedo.swift
rename Sources/CodeEditTextView/{Controller => TextView/Utils}/CEUndoManager.swift (87%)
diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift b/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift
index 962722b4d..39baa7b71 100644
--- a/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift
+++ b/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift
@@ -57,7 +57,7 @@ extension STTextViewController {
return event
}
- textViewUndoManager = CEUndoManager(textView: textView)
+// textViewUndoManager = CEUndoManager(textView: textView)
reloadUI()
setUpHighlighter()
setHighlightProvider(self.highlightProvider)
diff --git a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+TextInterface.swift b/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+TextInterface.swift
index 575203dcc..fb4b9f398 100644
--- a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+TextInterface.swift
+++ b/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+TextInterface.swift
@@ -41,9 +41,7 @@ extension STTextView: TextInterface {
}
fileprivate func registerUndo(_ mutation: TextMutation) {
- if let manager = undoManager as? CEUndoManager.DelegatedUndoManager {
- manager.registerMutation(mutation)
- }
+
}
public func applyMutationNoUndo(_ mutation: TextMutation) {
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
index dc311aeec..e8f758182 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -31,6 +31,7 @@ final class TextLayoutManager: NSObject {
private unowned var textStorage: NSTextStorage
private var lineStorage: TextLineStorage = TextLineStorage()
private let viewReuseQueue: ViewReuseQueue = ViewReuseQueue()
+ private var visibleLineIds: Set = []
weak private var layoutView: NSView?
@@ -198,53 +199,20 @@ final class TextLayoutManager: NSObject {
/// Invalidates layout for the given rect.
/// - Parameter rect: The rect to invalidate.
public func invalidateLayoutForRect(_ rect: NSRect) {
- guard let visibleRect = delegate?.visibleRect else { return }
- // The new view IDs
- var usedFragmentIDs = Set()
- // The IDs that were replaced and need removing.
- var existingFragmentIDs = Set()
- let minY = max(max(rect.minY, 0), visibleRect.minY)
- let maxY = min(rect.maxY, visibleRect.maxY)
-
- for linePosition in lineStorage.linesStartingAt(minY, until: maxY) {
- existingFragmentIDs.formUnion(Set(linePosition.data.typesetter.lineFragments.map(\.data.id)))
-
- let lineSize = layoutLine(
- linePosition,
- minY: linePosition.yPos,
- maxY: maxY,
- laidOutFragmentIDs: &usedFragmentIDs
- )
- if lineSize.height != linePosition.height {
- // If there's a height change, we need to lay out everything again and enqueue any views already used.
- viewReuseQueue.enqueueViews(in: usedFragmentIDs.union(existingFragmentIDs))
- layoutLines()
- return
- }
- if maxLineWidth < lineSize.width {
- maxLineWidth = lineSize.width
- }
+ for linePosition in lineStorage.linesStartingAt(rect.minY, until: rect.maxY) {
+ linePosition.data.setNeedsLayout()
}
-
- viewReuseQueue.enqueueViews(in: existingFragmentIDs)
+ layoutLines()
}
/// Invalidates layout for the given range of text.
/// - Parameter range: The range of text to invalidate.
public func invalidateLayoutForRange(_ range: NSRange) {
- // Determine the min/max Y value for this range and invalidate it
- guard let minPosition = lineStorage.getLine(atIndex: range.location),
- let maxPosition = lineStorage.getLine(atIndex: range.max) else {
- return
+ for linePosition in lineStorage.linesInRange(range) {
+ linePosition.data.setNeedsLayout()
}
- invalidateLayoutForRect(
- NSRect(
- x: 0,
- y: minPosition.yPos,
- width: 0,
- height: maxPosition.yPos + maxPosition.height
- )
- )
+
+ layoutLines()
}
// MARK: - Layout
@@ -256,70 +224,54 @@ final class TextLayoutManager: NSObject {
let maxY = visibleRect.maxY + 200
let originalHeight = lineStorage.height
var usedFragmentIDs = Set()
+ var forceLayout: Bool = false
+ let maxWidth: CGFloat = wrapLines
+ ? delegate?.textViewSize().width ?? .greatestFiniteMagnitude
+ : .greatestFiniteMagnitude
+ var newVisibleLines: Set = []
// Layout all lines
for linePosition in lineStorage.linesStartingAt(minY, until: maxY) {
- let lineSize = layoutLine(
- linePosition,
- minY: linePosition.yPos,
- maxY: maxY,
- laidOutFragmentIDs: &usedFragmentIDs
- )
- if lineSize.height != linePosition.height {
- lineStorage.update(
- atIndex: linePosition.range.location,
- delta: 0,
- deltaHeight: lineSize.height - linePosition.height
+ if forceLayout
+ || linePosition.data.needsLayout(maxWidth: maxWidth)
+ || !visibleLineIds.contains(linePosition.data.id) {
+ let lineSize = layoutLine(
+ linePosition,
+ minY: linePosition.yPos,
+ maxY: maxY,
+ maxWidth: maxWidth,
+ laidOutFragmentIDs: &usedFragmentIDs
)
+ if lineSize.height != linePosition.height {
+ lineStorage.update(
+ atIndex: linePosition.range.location,
+ delta: 0,
+ deltaHeight: lineSize.height - linePosition.height
+ )
+ // If we've updated a line's height, force re-layout for the rest of the pass.
+ forceLayout = true
+ }
+ if maxLineWidth < lineSize.width {
+ maxLineWidth = lineSize.width
+ }
+ } else {
+ // Make sure the used fragment views aren't dequeued.
+ usedFragmentIDs.formUnion(linePosition.data.typesetter.lineFragments.map(\.data.id))
}
- if maxLineWidth < lineSize.width {
- maxLineWidth = lineSize.width
- }
+ newVisibleLines.insert(linePosition.data.id)
}
// Enqueue any lines not used in this layout pass.
viewReuseQueue.enqueueViews(notInSet: usedFragmentIDs)
+ // Update the visible lines with the new set.
+ visibleLineIds = newVisibleLines
+
if originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height {
delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height)
}
}
- /// Lays out any lines that should be visible but are not laid out yet.
- /// - Parameter delta: If used a scroll view, the delta between the last y position and the current y position.
- /// Used to correctly update the view's height without jumping down in the active scroll.
- internal func updateVisibleLines(delta: CGFloat?) {
- // TODO: re-calculate layout after size change.
- // Get all visible lines and determine if more need to be laid out vertically.
- guard let visibleRect = delegate?.visibleRect else { return }
- let minY = max(visibleRect.minY - 200, 0)
- let maxY = visibleRect.maxY + 200
- let existingFragmentIDs = Set(viewReuseQueue.usedViews.keys)
- var usedFragmentIDs = Set()
-
- for linePosition in lineStorage.linesStartingAt(minY, until: maxY) {
- if linePosition.data.typesetter.lineFragments.isEmpty {
- usedFragmentIDs.forEach { viewId in
- viewReuseQueue.enqueueView(forKey: viewId)
- }
- layoutLines()
- return
- }
- for lineFragmentPosition in linePosition.data.typesetter.lineFragments {
- let lineFragment = lineFragmentPosition.data
- usedFragmentIDs.insert(lineFragment.id)
- if viewReuseQueue.usedViews[lineFragment.id] == nil {
- layoutFragmentView(
- for: lineFragmentPosition,
- at: linePosition.yPos + lineFragmentPosition.height
- )
- }
- }
- }
-
- viewReuseQueue.enqueueViews(in: existingFragmentIDs.subtracting(usedFragmentIDs))
- }
-
/// Lays out a single text line.
/// - Parameters:
/// - position: The line position from storage to use for layout.
@@ -331,13 +283,12 @@ final class TextLayoutManager: NSObject {
_ position: TextLineStorage.TextLinePosition,
minY: CGFloat,
maxY: CGFloat,
+ maxWidth: CGFloat,
laidOutFragmentIDs: inout Set
) -> CGSize {
let line = position.data
line.prepareForDisplay(
- maxWidth: wrapLines
- ? delegate?.textViewSize().width ?? .greatestFiniteMagnitude
- : .greatestFiniteMagnitude,
+ maxWidth: maxWidth,
lineHeightMultiplier: lineHeightMultiplier,
range: position.range
)
@@ -390,10 +341,7 @@ extension TextLayoutManager: NSTextStorageDelegate {
) {
if editedMask.contains(.editedCharacters) {
lineStorage.update(atIndex: editedRange.location, delta: delta, deltaHeight: 0)
- layoutLines()
- } else {
- invalidateLayoutForRange(editedRange)
- delegate?.textLayoutSetNeedsDisplay()
}
+ invalidateLayoutForRange(editedRange)
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
index 5e298a843..9e6c31773 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
@@ -11,20 +11,32 @@ import AppKit
/// Represents a displayable line of text.
final class TextLine: Identifiable {
let id: UUID = UUID()
- unowned var stringRef: NSTextStorage
+ weak var stringRef: NSTextStorage?
+ private var needsLayout: Bool = true
var maxWidth: CGFloat?
- let typesetter: Typesetter = Typesetter()
+ private(set) var typesetter: Typesetter = Typesetter()
init(stringRef: NSTextStorage) {
self.stringRef = stringRef
}
+ func setNeedsLayout() {
+ needsLayout = true
+ typesetter = Typesetter()
+ }
+
+ func needsLayout(maxWidth: CGFloat) -> Bool {
+ needsLayout || maxWidth != self.maxWidth
+ }
+
func prepareForDisplay(maxWidth: CGFloat, lineHeightMultiplier: CGFloat, range: NSRange) {
+ guard let string = stringRef?.attributedSubstring(from: range) else { return }
self.maxWidth = maxWidth
typesetter.prepareToTypeset(
- stringRef.attributedSubstring(from: range),
+ string,
maxWidth: maxWidth,
lineHeightMultiplier: lineHeightMultiplier
)
+ needsLayout = false
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
index f3dc5817f..76a0d046c 100644
--- a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
+++ b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
@@ -60,7 +60,7 @@ final class TextLineStorage {
// TODO: Cache this value & update on tree update
var first: TextLinePosition? {
guard length > 0,
- let position = search(for: length - 1) else {
+ let position = search(for: 0) else {
return nil
}
return TextLinePosition(position: position)
diff --git a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
index ed2244c16..f531d961d 100644
--- a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
@@ -51,7 +51,8 @@ class TextSelectionManager {
self.layoutManager = layoutManager
self.delegate = delegate
textSelections = [
- TextSelection(range: NSRange(location: 0, length: 0))
+ .init(range: NSRange(location: 0, length: 4)),
+ .init(range: NSRange(location: 6, length: 10))
]
updateSelectionViews()
}
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView+CopyPaste.swift b/Sources/CodeEditTextView/TextView/TextView/TextView+CopyPaste.swift
new file mode 100644
index 000000000..4283b16b0
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView+CopyPaste.swift
@@ -0,0 +1,32 @@
+//
+// TextView+CopyPaste.swift
+//
+//
+// Created by Khan Winter on 8/21/23.
+//
+
+import AppKit
+
+extension TextView {
+ @objc open func copy(_ sender: AnyObject) {
+ guard let textSelections = selectionManager?
+ .textSelections
+ .compactMap({ textStorage.attributedSubstring(from: $0.range) }),
+ !textSelections.isEmpty else { return }
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.writeObjects(textSelections)
+ }
+
+ @objc open func paste(_ sender: AnyObject) {
+ guard let stringContents = NSPasteboard.general.string(forType: .string) else { return }
+ insertText(stringContents, replacementRange: NSRange(location: NSNotFound, length: 0))
+ }
+
+ @objc open func cut(_ sender: AnyObject) {
+
+ }
+
+ @objc open func delete(_ sender: AnyObject) {
+
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView+Menu.swift b/Sources/CodeEditTextView/TextView/TextView/TextView+Menu.swift
new file mode 100644
index 000000000..ef55705f4
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView+Menu.swift
@@ -0,0 +1,21 @@
+//
+// TextView+Menu.swift
+//
+//
+// Created by Khan Winter on 8/21/23.
+//
+
+import AppKit
+
+extension TextView {
+ open override class var defaultMenu: NSMenu? {
+ let menu = NSMenu()
+
+ menu.items = [
+ NSMenuItem(title: "Copy", action: #selector(undo(_:)), keyEquivalent: "c"),
+ NSMenuItem(title: "Paste", action: #selector(undo(_:)), keyEquivalent: "v")
+ ]
+
+ return menu
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift b/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
index ac98b9671..f7d6f0a1c 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
@@ -6,6 +6,7 @@
//
import AppKit
+import TextStory
/**
# Marked Text Notes
@@ -34,9 +35,15 @@ extension TextView: NSTextInputClient {
case let string as NSString:
textStorage.replaceCharacters(in: selection.range, with: string as String)
selection.didInsertText(length: string.length)
+ _undoManager?.registerMutation(
+ TextMutation(string: string as String, range: selection.range, limit: textStorage.length)
+ )
case let string as NSAttributedString:
textStorage.replaceCharacters(in: selection.range, with: string)
selection.didInsertText(length: string.length)
+ _undoManager?.registerMutation(
+ TextMutation(string: string.string, range: selection.range, limit: textStorage.length)
+ )
default:
assertionFailure("\(#function) called with invalid string type. Expected String or NSAttributedString.")
}
@@ -79,10 +86,12 @@ extension TextView: NSTextInputClient {
}
@objc public func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect {
- .zero
+ print(#function)
+ return .zero
}
@objc public func characterIndex(for point: NSPoint) -> Int {
- layoutManager.textOffsetAtPoint(point) ?? NSNotFound
+ print(#function)
+ return layoutManager.textOffsetAtPoint(point) ?? NSNotFound
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView+UndoRedo.swift b/Sources/CodeEditTextView/TextView/TextView/TextView+UndoRedo.swift
new file mode 100644
index 000000000..a15892e16
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView+UndoRedo.swift
@@ -0,0 +1,27 @@
+//
+// TextView+UndoRedo.swift
+//
+//
+// Created by Khan Winter on 8/21/23.
+//
+
+import AppKit
+
+extension TextView {
+ override var undoManager: UndoManager? {
+ _undoManager?.manager
+ }
+
+ @objc func undo(_ sender: AnyObject?) {
+ if allowsUndo {
+ undoManager?.undo()
+ }
+ }
+
+ @objc func redo(_ sender: AnyObject?) {
+ if allowsUndo {
+ undoManager?.redo()
+ }
+ }
+
+}
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
index a5277ca18..66de0d768 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
@@ -53,6 +53,11 @@ class TextView: NSView, NSTextContent {
private(set) var layoutManager: TextLayoutManager!
private(set) var selectionManager: TextSelectionManager?
+ internal var isFirstResponder: Bool = false
+
+ var _undoManager: CEUndoManager?
+ @objc dynamic open var allowsUndo: Bool
+
var scrollView: NSScrollView? {
guard let enclosingScrollView, enclosingScrollView.documentView == self else { return nil }
return enclosingScrollView
@@ -78,6 +83,7 @@ class TextView: NSView, NSTextContent {
self.editorOverscroll = editorOverscroll
self.isEditable = isEditable
self.letterSpacing = letterSpacing
+ self.allowsUndo = true
super.init(frame: .zero)
@@ -112,12 +118,26 @@ class TextView: NSView, NSTextContent {
if isSelectable {
self.selectionManager = TextSelectionManager(layoutManager: layoutManager, delegate: self)
}
+
+ _undoManager = CEUndoManager(textView: self)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
+ // MARK: - First Responder
+
+ open override func becomeFirstResponder() -> Bool {
+ isFirstResponder = true
+ return super.becomeFirstResponder()
+ }
+
+ open override func resignFirstResponder() -> Bool {
+ isFirstResponder = false
+ return super.resignFirstResponder()
+ }
+
open override var canBecomeKeyView: Bool {
super.canBecomeKeyView && acceptsFirstResponder && !isHiddenOrHasHiddenAncestor
}
@@ -130,6 +150,10 @@ class TextView: NSView, NSTextContent {
isSelectable
}
+ open override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
+ return true
+ }
+
open override func resetCursorRects() {
super.resetCursorRects()
if isSelectable {
@@ -149,7 +173,7 @@ class TextView: NSView, NSTextContent {
updateFrameIfNeeded()
}
- // MARK: - Keys
+ // MARK: - Interaction
override func keyDown(with event: NSEvent) {
guard isEditable else {
@@ -161,6 +185,8 @@ class TextView: NSView, NSTextContent {
if !(inputContext?.handleEvent(event) ?? false) {
interpretKeyEvents([event])
+ } else {
+
}
}
@@ -171,6 +197,10 @@ class TextView: NSView, NSTextContent {
return
}
selectionManager?.setSelectedRange(NSRange(location: offset, length: 0))
+
+ if !self.isFirstResponder {
+ self.window?.makeFirstResponder(self)
+ }
}
// MARK: - Draw
@@ -181,8 +211,10 @@ class TextView: NSView, NSTextContent {
override var visibleRect: NSRect {
if let scrollView = scrollView {
- // +200px vertically for a bit of padding
- return scrollView.documentVisibleRect.insetBy(dx: 0, dy: -400).offsetBy(dx: 0, dy: 200)
+ var rect = scrollView.documentVisibleRect
+ rect.origin.y += scrollView.contentInsets.top
+ rect.size.height -= scrollView.contentInsets.top + scrollView.contentInsets.bottom
+ return rect
} else {
return super.visibleRect
}
@@ -226,12 +258,17 @@ class TextView: NSView, NSTextContent {
needsLayout = true
needsDisplay = true
layoutManager.layoutLines()
- } else {
- layoutManager.updateVisibleLines(delta: nil)
}
selectionManager?.updateSelectionViews()
}
+
+ deinit {
+ layoutManager = nil
+ selectionManager = nil
+ textStorage = nil
+ NotificationCenter.default.removeObserver(self)
+ }
}
// MARK: - TextLayoutManagerDelegate
diff --git a/Sources/CodeEditTextView/TextView/TextViewController.swift b/Sources/CodeEditTextView/TextView/TextViewController.swift
index d37147c5d..1706303d1 100644
--- a/Sources/CodeEditTextView/TextView/TextViewController.swift
+++ b/Sources/CodeEditTextView/TextView/TextViewController.swift
@@ -31,7 +31,6 @@ public class TextViewController: NSViewController {
private var storageDelegate: MultiStorageDelegate!
private var highlighter: Highlighter?
- private var lastScrollPosition: CGFloat = 0
init(
string: Binding,
@@ -111,18 +110,25 @@ public class TextViewController: NSViewController {
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
+ // Layout on scroll change
NotificationCenter.default.addObserver(
forName: NSView.boundsDidChangeNotification,
object: scrollView.contentView,
queue: .main
) { _ in
- self.lastScrollPosition = self.scrollView.documentVisibleRect.origin.y
- self.textView.layoutManager.updateVisibleLines(
- delta: self.scrollView.documentVisibleRect.origin.y - self.lastScrollPosition
- )
+ self.textView.layoutManager.layoutLines()
self.textView.inputContext?.invalidateCharacterCoordinates()
}
+ // Layout on frame change
+ NotificationCenter.default.addObserver(
+ forName: NSView.frameDidChangeNotification,
+ object: scrollView.contentView,
+ queue: .main
+ ) { _ in
+ self.textView.layoutManager.layoutLines()
+ }
+
textView.updateFrameIfNeeded()
}
}
diff --git a/Sources/CodeEditTextView/Controller/CEUndoManager.swift b/Sources/CodeEditTextView/TextView/Utils/CEUndoManager.swift
similarity index 87%
rename from Sources/CodeEditTextView/Controller/CEUndoManager.swift
rename to Sources/CodeEditTextView/TextView/Utils/CEUndoManager.swift
index 1f1ad4b56..d6ca91cb5 100644
--- a/Sources/CodeEditTextView/Controller/CEUndoManager.swift
+++ b/Sources/CodeEditTextView/TextView/Utils/CEUndoManager.swift
@@ -71,10 +71,10 @@ class CEUndoManager {
/// A stack of operations that can be redone.
private var redoStack: [UndoGroup] = []
- private unowned let textView: STTextView
+ private weak var textView: TextView?
private(set) var isGrouping: Bool = false
- public init(textView: STTextView) {
+ public init(textView: TextView) {
self.textView = textView
self.manager = DelegatedUndoManager()
manager.parent = self
@@ -82,26 +82,30 @@ class CEUndoManager {
/// Performs an undo operation if there is one available.
public func undo() {
- guard let item = undoStack.popLast() else {
+ guard let item = undoStack.popLast(), let textView else {
return
}
isUndoing = true
+ textView.textStorage.beginEditing()
for mutation in item.mutations.reversed() {
- textView.applyMutationNoUndo(mutation.inverse)
+ textView.textStorage.applyMutation(mutation.inverse)
}
+ textView.textStorage.endEditing()
redoStack.append(item)
isUndoing = false
}
/// Performs a redo operation if there is one available.
public func redo() {
- guard let item = redoStack.popLast() else {
+ guard let item = redoStack.popLast(), let textView else {
return
}
isRedoing = true
+ textView.textStorage.beginEditing()
for mutation in item.mutations {
- textView.applyMutationNoUndo(mutation.mutation)
+ textView.textStorage.applyMutation(mutation.mutation)
}
+ textView.textStorage.endEditing()
undoStack.append(item)
isRedoing = false
}
@@ -117,8 +121,17 @@ class CEUndoManager {
/// Calling this method while the manager is in an undo/redo operation will result in a no-op.
/// - Parameter mutation: The mutation to register for undo/redo
public func registerMutation(_ mutation: TextMutation) {
- if (mutation.range.length == 0 && mutation.string.isEmpty) || isUndoing || isRedoing { return }
- let newMutation = UndoGroup.Mutation(mutation: mutation, inverse: textView.inverseMutation(for: mutation))
+ dump(mutation)
+ guard let textView,
+ let textStorage = textView.textStorage,
+ mutation.range.length > 0,
+ !mutation.string.isEmpty,
+ !isUndoing,
+ !isRedoing else {
+ return
+ }
+ let newMutation = UndoGroup.Mutation(mutation: mutation, inverse: textStorage.inverseMutation(for: mutation))
+ print(#function)
if !undoStack.isEmpty, let lastMutation = undoStack.last?.mutations.last {
if isGrouping || shouldContinueGroup(newMutation, lastMutation: lastMutation) {
undoStack[undoStack.count - 1].mutations.append(newMutation)
diff --git a/Sources/CodeEditTextView/TextView/Utils/LineEnding.swift b/Sources/CodeEditTextView/TextView/Utils/LineEnding.swift
index fcee4e702..a54dfd543 100644
--- a/Sources/CodeEditTextView/TextView/Utils/LineEnding.swift
+++ b/Sources/CodeEditTextView/TextView/Utils/LineEnding.swift
@@ -45,7 +45,7 @@ enum LineEnding: String {
var lineIterator = lineStorage.makeIterator()
while let line = lineIterator.next(), shouldContinue {
- guard let lineString = line.data.stringRef.substring(from: line.range),
+ guard let lineString = line.data.stringRef?.substring(from: line.range),
let lineEnding = LineEnding(line: lineString) else {
continue
}
diff --git a/TODO.md b/TODO.md
index e6d244557..7f1e26bbb 100644
--- a/TODO.md
+++ b/TODO.md
@@ -3,8 +3,9 @@
- [X] load file
- [X] render text
- [X] scroll
- - [] When scrolling, if width changed & scrolling up, batch layout a couple hundred lines (test for correct # for
- speed), update height and scroll offset to keep user in same spot.
+ - [x] ~~When scrolling, if width changed & scrolling up, batch layout a couple hundred lines (test for correct number for
+ speed), update height and scroll offset to keep user in same spot.~~
+ - [x] Layout all visible lines on scroll & resize updates
- [X] wrap text
- [X] resize correctly
- [x] syntax highlighting
diff --git a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
index 0b74cff11..2eb83c979 100644
--- a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
+++ b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
@@ -72,4 +72,23 @@ final class TextLayoutLineStorageTests: XCTestCase {
tree.build(from: lines, estimatedLineHeight: 1.0)
}
}
+
+ func test_iterationPerformance() {
+ let tree = TextLineStorage()
+ let stringRef = NSTextStorage(string: "")
+ var lines: [(TextLine, Int)] = []
+ for i in 0..<100_000 {
+ lines.append((
+ TextLine(stringRef: stringRef),
+ i + 1
+ ))
+ }
+ tree.build(from: lines, estimatedLineHeight: 1.0)
+
+ measure {
+ for line in tree {
+ let _ = line
+ }
+ }
+ }
}
From c3c9e6297f1c276d6093c74066a6a7c05cfd8f75 Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Mon, 21 Aug 2023 22:41:06 -0500
Subject: [PATCH 15/75] Fix scroll jumping on scroll up
---
.../TextLayoutManager/TextLayoutManager.swift | 11 +++++++++++
.../CodeEditTextView/TextView/TextView/TextView.swift | 6 ++++++
.../TextView/Utils/CEUndoManager.swift | 2 --
3 files changed, 17 insertions(+), 2 deletions(-)
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
index e8f758182..0cce586c9 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -13,6 +13,7 @@ protocol TextLayoutManagerDelegate: AnyObject {
func layoutManagerMaxWidthDidChange(newWidth: CGFloat)
func textViewSize() -> CGSize
func textLayoutSetNeedsDisplay()
+ func layoutManagerYAdjustment(_ yAdjustment: CGFloat)
var visibleRect: NSRect { get }
}
@@ -229,6 +230,7 @@ final class TextLayoutManager: NSObject {
? delegate?.textViewSize().width ?? .greatestFiniteMagnitude
: .greatestFiniteMagnitude
var newVisibleLines: Set = []
+ var yContentAdjustment: CGFloat = 0
// Layout all lines
for linePosition in lineStorage.linesStartingAt(minY, until: maxY) {
@@ -250,6 +252,11 @@ final class TextLayoutManager: NSObject {
)
// If we've updated a line's height, force re-layout for the rest of the pass.
forceLayout = true
+
+ if linePosition.yPos < minY {
+ // Adjust the scroll position by the difference between the new height and old.
+ yContentAdjustment += lineSize.height - linePosition.height
+ }
}
if maxLineWidth < lineSize.width {
maxLineWidth = lineSize.width
@@ -270,6 +277,10 @@ final class TextLayoutManager: NSObject {
if originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height {
delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height)
}
+
+ if yContentAdjustment != 0 {
+ delegate?.layoutManagerYAdjustment(yContentAdjustment)
+ }
}
/// Lays out a single text line.
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
index 66de0d768..643a4eb00 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
@@ -296,6 +296,12 @@ extension TextView: TextLayoutManagerDelegate {
needsDisplay = true
needsLayout = true
}
+
+ func layoutManagerYAdjustment(_ yAdjustment: CGFloat) {
+ var point = scrollView?.documentVisibleRect.origin ?? .zero
+ point.y += yAdjustment
+ scrollView?.documentView?.scroll(point)
+ }
}
// MARK: - TextSelectionManagerDelegate
diff --git a/Sources/CodeEditTextView/TextView/Utils/CEUndoManager.swift b/Sources/CodeEditTextView/TextView/Utils/CEUndoManager.swift
index d6ca91cb5..6bc6af50e 100644
--- a/Sources/CodeEditTextView/TextView/Utils/CEUndoManager.swift
+++ b/Sources/CodeEditTextView/TextView/Utils/CEUndoManager.swift
@@ -121,7 +121,6 @@ class CEUndoManager {
/// Calling this method while the manager is in an undo/redo operation will result in a no-op.
/// - Parameter mutation: The mutation to register for undo/redo
public func registerMutation(_ mutation: TextMutation) {
- dump(mutation)
guard let textView,
let textStorage = textView.textStorage,
mutation.range.length > 0,
@@ -131,7 +130,6 @@ class CEUndoManager {
return
}
let newMutation = UndoGroup.Mutation(mutation: mutation, inverse: textStorage.inverseMutation(for: mutation))
- print(#function)
if !undoStack.isEmpty, let lastMutation = undoStack.last?.mutations.last {
if isGrouping || shouldContinueGroup(newMutation, lastMutation: lastMutation) {
undoStack[undoStack.count - 1].mutations.append(newMutation)
From c37ab5cb633e18b985f0328a7db2a612af3ad270 Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Mon, 21 Aug 2023 22:46:29 -0500
Subject: [PATCH 16/75] Add setNeedsLayout to TextLayoutManager
---
.../TextLayoutManager/TextLayoutManager.swift | 11 ++++++++++-
.../TextView/TextView/TextView+NSTextInput.swift | 1 +
2 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
index 0cce586c9..2a5080ad8 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -33,6 +33,8 @@ final class TextLayoutManager: NSObject {
private var lineStorage: TextLineStorage = TextLineStorage()
private let viewReuseQueue: ViewReuseQueue = ViewReuseQueue()
private var visibleLineIds: Set = []
+ /// Used to force a complete re-layout using `setNeedsLayout`
+ private var needsLayout: Bool = false
weak private var layoutView: NSView?
@@ -216,6 +218,11 @@ final class TextLayoutManager: NSObject {
layoutLines()
}
+ func setNeedsLayout() {
+ needsLayout = true
+ visibleLineIds.removeAll(keepingCapacity: true)
+ }
+
// MARK: - Layout
/// Lays out all visible lines
@@ -225,7 +232,7 @@ final class TextLayoutManager: NSObject {
let maxY = visibleRect.maxY + 200
let originalHeight = lineStorage.height
var usedFragmentIDs = Set()
- var forceLayout: Bool = false
+ var forceLayout: Bool = needsLayout
let maxWidth: CGFloat = wrapLines
? delegate?.textViewSize().width ?? .greatestFiniteMagnitude
: .greatestFiniteMagnitude
@@ -281,6 +288,8 @@ final class TextLayoutManager: NSObject {
if yContentAdjustment != 0 {
delegate?.layoutManagerYAdjustment(yContentAdjustment)
}
+
+ needsLayout = false
}
/// Lays out a single text line.
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift b/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
index f7d6f0a1c..7325f7ed5 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
@@ -29,6 +29,7 @@ import TextStory
extension TextView: NSTextInputClient {
@objc public func insertText(_ string: Any, replacementRange: NSRange) {
guard isEditable else { return }
+ layoutManager.setNeedsLayout() // force a complete layout when the storage edit is committed.
textStorage.beginEditing()
selectionManager?.textSelections.forEach { selection in
switch string {
From 417bd32b87a2d1e9f3c802dde526b0b75a7c88d9 Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Mon, 21 Aug 2023 22:55:35 -0500
Subject: [PATCH 17/75] Move line storage builder
---
.../TextLayoutManager/TextLayoutManager.swift | 32 +------------
.../TextLineStorage+NSTextStorage.swift | 48 +++++++++++++++++++
2 files changed, 49 insertions(+), 31 deletions(-)
create mode 100644 Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+NSTextStorage.swift
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
index 2a5080ad8..e3c0614b1 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -79,37 +79,7 @@ final class TextLayoutManager: NSObject {
let start = mach_absolute_time()
#endif
- func getNextLine(startingAt location: Int) -> NSRange? {
- let range = NSRange(location: location, length: 0)
- var end: Int = NSNotFound
- var contentsEnd: Int = NSNotFound
- (textStorage.string as NSString).getLineStart(nil, end: &end, contentsEnd: &contentsEnd, for: range)
- if end != NSNotFound && contentsEnd != NSNotFound && end != contentsEnd {
- return NSRange(location: contentsEnd, length: end - contentsEnd)
- } else {
- return nil
- }
- }
-
- var index = 0
- var lines: [(TextLine, Int)] = []
- while let range = getNextLine(startingAt: index) {
- lines.append((
- TextLine(stringRef: textStorage),
- range.max - index
- ))
- index = NSMaxRange(range)
- }
- // Create the last line
- if textStorage.length - index > 0 {
- lines.append((
- TextLine(stringRef: textStorage),
- textStorage.length - index
- ))
- }
-
- // Use an efficient tree building algorithm rather than adding lines sequentially
- lineStorage.build(from: lines, estimatedLineHeight: estimateLineHeight())
+ lineStorage.buildFromTextStorage(textStorage, estimatedLineHeight: estimateLineHeight())
detectedLineEnding = LineEnding.detectLineEnding(lineStorage: lineStorage)
#if DEBUG
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+NSTextStorage.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+NSTextStorage.swift
new file mode 100644
index 000000000..25e78ebbd
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+NSTextStorage.swift
@@ -0,0 +1,48 @@
+//
+// File.swift
+//
+//
+// Created by Khan Winter on 8/21/23.
+//
+
+import AppKit
+
+extension TextLineStorage where Data == TextLine {
+ /// Builds the line storage object from the given `NSTextStorage`.
+ /// - Parameters:
+ /// - textStorage: The text storage object to use.
+ /// - estimatedLineHeight: The estimated height of each individual line.
+ func buildFromTextStorage(_ textStorage: NSTextStorage, estimatedLineHeight: CGFloat) {
+ func getNextLine(startingAt location: Int) -> NSRange? {
+ let range = NSRange(location: location, length: 0)
+ var end: Int = NSNotFound
+ var contentsEnd: Int = NSNotFound
+ (textStorage.string as NSString).getLineStart(nil, end: &end, contentsEnd: &contentsEnd, for: range)
+ if end != NSNotFound && contentsEnd != NSNotFound && end != contentsEnd {
+ return NSRange(location: contentsEnd, length: end - contentsEnd)
+ } else {
+ return nil
+ }
+ }
+
+ var index = 0
+ var lines: [(TextLine, Int)] = []
+ while let range = getNextLine(startingAt: index) {
+ lines.append((
+ TextLine(stringRef: textStorage),
+ range.max - index
+ ))
+ index = NSMaxRange(range)
+ }
+ // Create the last line
+ if textStorage.length - index > 0 {
+ lines.append((
+ TextLine(stringRef: textStorage),
+ textStorage.length - index
+ ))
+ }
+
+ // Use an efficient tree building algorithm rather than adding lines sequentially
+ self.build(from: lines, estimatedLineHeight: estimatedLineHeight)
+ }
+}
From f5cc9f669aa14a81112afb29f08ad625f305546f Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Tue, 22 Aug 2023 00:27:55 -0500
Subject: [PATCH 18/75] Add line number to Line Storage nodes
---
.../Controller/STTextViewController.swift | 4 +-
.../Extensions/NSFont+RulerFont.swift | 10 ++---
.../TextLayoutManager+Iterator.swift | 32 ++++++++++++++
.../TextLayoutManager/TextLayoutManager.swift | 8 ++--
.../TextLineStorage+Cache.swift | 14 -------
.../TextLineStorage+Iterator.swift | 6 +--
.../TextLineStorage+Node.swift | 8 +++-
.../TextLineStorage/TextLineStorage.swift | 42 ++++++++++++++-----
.../TextView/TextView/TextView.swift | 2 +-
.../TextView/TextViewController.swift | 2 +-
10 files changed, 87 insertions(+), 41 deletions(-)
create mode 100644 Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager+Iterator.swift
delete mode 100644 Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Cache.swift
diff --git a/Sources/CodeEditTextView/Controller/STTextViewController.swift b/Sources/CodeEditTextView/Controller/STTextViewController.swift
index 5d9c4350e..f320f0ea5 100644
--- a/Sources/CodeEditTextView/Controller/STTextViewController.swift
+++ b/Sources/CodeEditTextView/Controller/STTextViewController.swift
@@ -208,10 +208,10 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt
rulerView.baselineOffset = baselineOffset
rulerView.highlightSelectedLine = isEditable
rulerView.rulerInsets = STRulerInsets(leading: 12, trailing: 8)
- rulerView.font = rulerFont
+ rulerView.font = font.rulerFont
rulerView.backgroundColor = theme.background
rulerView.ruleThickness = max(
- NSString(string: "1000").size(withAttributes: [.font: rulerFont]).width
+ NSString(string: "1000").size(withAttributes: [.font: font.rulerFont]).width
+ rulerView.rulerInsets.leading
+ rulerView.rulerInsets.trailing,
rulerView.ruleThickness
diff --git a/Sources/CodeEditTextView/Extensions/NSFont+RulerFont.swift b/Sources/CodeEditTextView/Extensions/NSFont+RulerFont.swift
index b733dded0..1386de188 100644
--- a/Sources/CodeEditTextView/Extensions/NSFont+RulerFont.swift
+++ b/Sources/CodeEditTextView/Extensions/NSFont+RulerFont.swift
@@ -8,11 +8,11 @@
import Foundation
import AppKit
-extension STTextViewController {
+extension NSFont {
var rulerFont: NSFont {
- let fontSize: Double = (font.pointSize - 1) + 0.25
- let fontAdvance: Double = font.pointSize * 0.49 + 0.1
- let fontWeight = NSFont.Weight(rawValue: font.pointSize * 0.00001 + 0.0001)
+ let fontSize: Double = (self.pointSize - 1) + 0.25
+ let fontAdvance: Double = self.pointSize * 0.49 + 0.1
+ let fontWeight = NSFont.Weight(rawValue: self.pointSize * 0.00001 + 0.0001)
let fontWidth = NSFont.Width(rawValue: -0.13)
let font = NSFont.systemFont(ofSize: fontSize, weight: fontWeight, width: fontWidth)
@@ -36,7 +36,7 @@ extension STTextViewController {
]
let features = [alt4, alt6and9, monoSpaceDigits]
- let descriptor = font.fontDescriptor.addingAttributes([.featureSettings: features, .fixedAdvance: fontAdvance])
+ let descriptor = self.fontDescriptor.addingAttributes([.featureSettings: features, .fixedAdvance: fontAdvance])
return NSFont(descriptor: descriptor, size: 0) ?? font
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager+Iterator.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager+Iterator.swift
new file mode 100644
index 000000000..094dd4c90
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager+Iterator.swift
@@ -0,0 +1,32 @@
+//
+// TextLayoutManager+Iterator.swift
+//
+//
+// Created by Khan Winter on 8/21/23.
+//
+
+import Foundation
+
+extension TextLayoutManager {
+ func visibleLines() -> Iterator {
+ let visibleRect = delegate?.visibleRect ?? NSRect(
+ x: 0,
+ y: 0,
+ width: 0,
+ height: estimatedHeight()
+ )
+ return Iterator(minY: max(visibleRect.minY, 0), maxY: max(visibleRect.maxY, 0), storage: self.lineStorage)
+ }
+
+ struct Iterator: LazySequenceProtocol, IteratorProtocol {
+ private var storageIterator: TextLineStorage.TextLineStorageYIterator
+
+ init(minY: CGFloat, maxY: CGFloat, storage: TextLineStorage) {
+ storageIterator = storage.linesStartingAt(minY, until: maxY)
+ }
+
+ mutating func next() -> TextLineStorage.TextLinePosition? {
+ storageIterator.next()
+ }
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
index e3c0614b1..35b10a9c2 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -30,7 +30,7 @@ final class TextLayoutManager: NSObject {
// MARK: - Internal
private unowned var textStorage: NSTextStorage
- private var lineStorage: TextLineStorage = TextLineStorage()
+ internal var lineStorage: TextLineStorage = TextLineStorage()
private let viewReuseQueue: ViewReuseQueue = ViewReuseQueue()
private var visibleLineIds: Set = []
/// Used to force a complete re-layout using `setNeedsLayout`
@@ -198,8 +198,8 @@ final class TextLayoutManager: NSObject {
/// Lays out all visible lines
internal func layoutLines() {
guard let visibleRect = delegate?.visibleRect else { return }
- let minY = max(visibleRect.minY - 200, 0)
- let maxY = visibleRect.maxY + 200
+ let minY = max(visibleRect.minY, 0)
+ let maxY = max(visibleRect.maxY, 0)
let originalHeight = lineStorage.height
var usedFragmentIDs = Set()
var forceLayout: Bool = needsLayout
@@ -322,6 +322,8 @@ final class TextLayoutManager: NSObject {
}
}
+// MARK: - Edits
+
extension TextLayoutManager: NSTextStorageDelegate {
func textStorage(
_ textStorage: NSTextStorage,
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Cache.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Cache.swift
deleted file mode 100644
index 0efbf26a5..000000000
--- a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Cache.swift
+++ /dev/null
@@ -1,14 +0,0 @@
-//
-// TextLineStorage+Cache.swift
-//
-//
-// Created by Khan Winter on 7/15/23.
-//
-
-import Foundation
-
-extension TextLineStorage {
- class Cache {
- // TODO: Cache nodes for efficient fetching & updating
- }
-}
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Iterator.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Iterator.swift
index a676e519c..ea55e01c8 100644
--- a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Iterator.swift
+++ b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Iterator.swift
@@ -16,7 +16,7 @@ extension TextLineStorage {
TextLineStorageRangeIterator(storage: self, range: range)
}
- struct TextLineStorageYIterator: Sequence, IteratorProtocol {
+ struct TextLineStorageYIterator: LazySequenceProtocol, IteratorProtocol {
private let storage: TextLineStorage
private let minY: CGFloat
private let maxY: CGFloat
@@ -46,7 +46,7 @@ extension TextLineStorage {
}
}
- struct TextLineStorageRangeIterator: Sequence, IteratorProtocol {
+ struct TextLineStorageRangeIterator: LazySequenceProtocol, IteratorProtocol {
private let storage: TextLineStorage
private let range: NSRange
private var currentPosition: TextLinePosition?
@@ -75,7 +75,7 @@ extension TextLineStorage {
}
}
-extension TextLineStorage: Sequence {
+extension TextLineStorage: LazySequenceProtocol {
func makeIterator() -> TextLineStorageIterator {
TextLineStorageIterator(storage: self, currentPosition: nil)
}
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift
index 53d9663cf..d78dc8d35 100644
--- a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift
+++ b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift
@@ -32,12 +32,16 @@ extension TextLineStorage {
// The length of the text line
var length: Int
+ // The height of this text line
+ var height: CGFloat
var data: Data
// The offset in characters of the entire left subtree
var leftSubtreeOffset: Int
+ // The sum of the height of the nodes in the left subtree
var leftSubtreeHeight: CGFloat
- var height: CGFloat
+ // The number of nodes in the left subtree
+ var leftSubtreeCount: Int
var left: Node?
var right: Node?
@@ -49,6 +53,7 @@ extension TextLineStorage {
data: Data,
leftSubtreeOffset: Int,
leftSubtreeHeight: CGFloat,
+ leftSubtreeCount: Int,
height: CGFloat,
left: Node? = nil,
right: Node? = nil,
@@ -59,6 +64,7 @@ extension TextLineStorage {
self.data = data
self.leftSubtreeOffset = leftSubtreeOffset
self.leftSubtreeHeight = leftSubtreeHeight
+ self.leftSubtreeCount = leftSubtreeCount
self.height = height
self.left = left
self.right = right
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
index 76a0d046c..fcdb33989 100644
--- a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
+++ b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
@@ -10,11 +10,12 @@ import Foundation
/// Implements a red-black tree for efficiently editing, storing and retrieving `TextLine`s.
final class TextLineStorage {
struct TextLinePosition {
- init(data: Data, range: NSRange, yPos: CGFloat, height: CGFloat) {
+ init(data: Data, range: NSRange, yPos: CGFloat, height: CGFloat, index: Int) {
self.data = data
self.range = range
self.yPos = yPos
self.height = height
+ self.index = index
}
init(position: NodePosition) {
@@ -22,6 +23,7 @@ final class TextLineStorage {
self.range = NSRange(location: position.textPos, length: position.node.length)
self.yPos = position.yPos
self.height = position.node.height
+ self.index = position.index
}
/// The data stored at the position
@@ -32,6 +34,8 @@ final class TextLineStorage {
let yPos: CGFloat
/// The height of the stored data
let height: CGFloat
+ /// The index of the position.
+ let index: Int
}
internal struct NodePosition {
@@ -41,6 +45,8 @@ final class TextLineStorage {
let yPos: CGFloat
/// The location of the node in the document
let textPos: Int
+ /// The index of the node in the document.
+ let index: Int
}
#if DEBUG
@@ -96,6 +102,7 @@ final class TextLineStorage {
data: line,
leftSubtreeOffset: 0,
leftSubtreeHeight: 0.0,
+ leftSubtreeCount: 0,
height: height,
color: .black
)
@@ -151,6 +158,7 @@ final class TextLineStorage {
var currentNode = root
var currentOffset: Int = root?.leftSubtreeOffset ?? 0
var currentYPosition: CGFloat = root?.leftSubtreeHeight ?? 0
+ var currentIndex: Int = root?.leftSubtreeCount ?? 0
while let node = currentNode {
// If index is in the range [currentOffset..= currentYPosition && posY < currentYPosition + node.height {
@@ -158,16 +166,19 @@ final class TextLineStorage {
data: node.data,
range: NSRange(location: currentOffset, length: node.length),
yPos: currentYPosition,
- height: node.height
+ height: node.height,
+ index: currentIndex
)
} else if currentYPosition > posY {
currentNode = node.left
currentOffset = (currentOffset - node.leftSubtreeOffset) + (node.left?.leftSubtreeOffset ?? 0)
currentYPosition = (currentYPosition - node.leftSubtreeHeight) + (node.left?.leftSubtreeHeight ?? 0)
+ currentIndex = (currentIndex - node.leftSubtreeCount) + (node.left?.leftSubtreeCount ?? 0)
} else if node.leftSubtreeHeight < posY {
currentNode = node.right
currentOffset += node.length + (node.right?.leftSubtreeOffset ?? 0)
currentYPosition += node.height + (node.right?.leftSubtreeHeight ?? 0)
+ currentIndex += 1 + (node.right?.leftSubtreeCount ?? 0)
} else {
currentNode = nil
}
@@ -208,7 +219,7 @@ final class TextLineStorage {
height += deltaHeight
position.node.length += delta
position.node.height += deltaHeight
- metaFixup(startingAt: position.node, delta: delta, deltaHeight: deltaHeight)
+ metaFixup(startingAt: position.node, delta: delta, deltaHeight: deltaHeight, insertedNode: false)
}
/// Deletes a line at the given index.
@@ -265,27 +276,28 @@ final class TextLineStorage {
left: Int,
right: Int,
parent: Node?
- ) -> (Node?, Int?, CGFloat?) { // swiftlint:disable:this large_tuple
- guard left < right else { return (nil, nil, nil) }
+ ) -> (Node?, Int?, CGFloat?, Int) { // swiftlint:disable:this large_tuple
+ guard left < right else { return (nil, nil, nil, 0) }
let mid = left + (right - left)/2
let node = Node(
length: lines[mid].1,
data: lines[mid].0,
leftSubtreeOffset: 0,
leftSubtreeHeight: 0,
+ leftSubtreeCount: 0,
height: estimatedLineHeight,
color: .black
)
node.parent = parent
- let (left, leftOffset, leftHeight) = build(
+ let (left, leftOffset, leftHeight, leftCount) = build(
lines: lines,
estimatedLineHeight: estimatedLineHeight,
left: left,
right: mid,
parent: node
)
- let (right, rightOffset, rightHeight) = build(
+ let (right, rightOffset, rightHeight, rightCount) = build(
lines: lines,
estimatedLineHeight: estimatedLineHeight,
left: mid + 1,
@@ -303,11 +315,13 @@ final class TextLineStorage {
height += node.height
node.leftSubtreeOffset = leftOffset ?? 0
node.leftSubtreeHeight = leftHeight ?? 0
+ node.leftSubtreeCount = leftCount
return (
node,
node.length + (leftOffset ?? 0) + (rightOffset ?? 0),
- node.height + (leftHeight ?? 0) + (rightHeight ?? 0)
+ node.height + (leftHeight ?? 0) + (rightHeight ?? 0),
+ 1 + leftCount + rightCount
)
}
}
@@ -322,18 +336,21 @@ private extension TextLineStorage {
var currentNode = root
var currentOffset: Int = root?.leftSubtreeOffset ?? 0
var currentYPosition: CGFloat = root?.leftSubtreeHeight ?? 0
+ var currentIndex: Int = root?.leftSubtreeCount ?? 0
while let node = currentNode {
// If index is in the range [currentOffset..= currentOffset && index < currentOffset + node.length {
- return NodePosition(node: node, yPos: currentYPosition, textPos: currentOffset)
+ return NodePosition(node: node, yPos: currentYPosition, textPos: currentOffset, index: currentIndex)
} else if currentOffset > index {
currentNode = node.left
currentOffset = (currentOffset - node.leftSubtreeOffset) + (node.left?.leftSubtreeOffset ?? 0)
currentYPosition = (currentYPosition - node.leftSubtreeHeight) + (node.left?.leftSubtreeHeight ?? 0)
+ currentIndex = (currentIndex - node.leftSubtreeCount) + (node.left?.leftSubtreeCount ?? 0)
} else if node.leftSubtreeOffset < index {
currentNode = node.right
currentOffset += node.length + (node.right?.leftSubtreeOffset ?? 0)
currentYPosition += node.height + (node.right?.leftSubtreeHeight ?? 0)
+ currentIndex += 1 + (node.right?.leftSubtreeCount ?? 0)
} else {
currentNode = nil
}
@@ -345,7 +362,7 @@ private extension TextLineStorage {
// MARK: - Fixup
func insertFixup(node: Node) {
- metaFixup(startingAt: node, delta: node.length, deltaHeight: node.height)
+ metaFixup(startingAt: node, delta: node.length, deltaHeight: node.height, insertedNode: true)
var nextNode: Node? = node
while var nodeX = nextNode, nodeX != root, let nodeXParent = nodeX.parent, nodeXParent.color == .red {
@@ -398,13 +415,14 @@ private extension TextLineStorage {
}
/// Walk up the tree, updating any `leftSubtree` metadata.
- func metaFixup(startingAt node: Node, delta: Int, deltaHeight: CGFloat) {
+ func metaFixup(startingAt node: Node, delta: Int, deltaHeight: CGFloat, insertedNode: Bool) {
guard node.parent != nil else { return }
var node: Node? = node
while node != nil, node != root {
if isLeftChild(node!) {
node?.parent?.leftSubtreeOffset += delta
node?.parent?.leftSubtreeHeight += deltaHeight
+ node?.parent?.leftSubtreeCount += insertedNode ? 1 : 0
}
node = node?.parent
}
@@ -434,6 +452,7 @@ private extension TextLineStorage {
nodeY = node.right
nodeY?.leftSubtreeOffset += node.leftSubtreeOffset + node.length
nodeY?.leftSubtreeHeight += node.leftSubtreeHeight + node.height
+ nodeY?.leftSubtreeCount += node.leftSubtreeCount + 1
node.right = nodeY?.left
node.right?.parent = node
} else {
@@ -459,6 +478,7 @@ private extension TextLineStorage {
nodeY?.right = node
node.leftSubtreeOffset = (node.left?.length ?? 0) + (node.left?.leftSubtreeOffset ?? 0)
node.leftSubtreeHeight = (node.left?.height ?? 0) + (node.left?.leftSubtreeHeight ?? 0)
+ node.leftSubtreeCount = (node.left == nil ? 1 : 0) + (node.left?.leftSubtreeCount ?? 0)
}
node.parent = nodeY
}
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
index 643a4eb00..42a105f19 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
@@ -186,7 +186,7 @@ class TextView: NSView, NSTextContent {
if !(inputContext?.handleEvent(event) ?? false) {
interpretKeyEvents([event])
} else {
-
+
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextViewController.swift b/Sources/CodeEditTextView/TextView/TextViewController.swift
index 1706303d1..02b52e036 100644
--- a/Sources/CodeEditTextView/TextView/TextViewController.swift
+++ b/Sources/CodeEditTextView/TextView/TextViewController.swift
@@ -91,7 +91,7 @@ public class TextViewController: NSViewController {
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.contentView.postsFrameChangedNotifications = true
scrollView.hasVerticalScroller = true
- scrollView.hasHorizontalRuler = true
+ scrollView.hasHorizontalScroller = true
scrollView.documentView = textView
scrollView.contentView.postsBoundsChangedNotifications = true
if let contentInsets {
From fbe25d8cca484496b4e8e08fa65011662c7ab316 Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Tue, 22 Aug 2023 13:22:15 -0500
Subject: [PATCH 19/75] Add GutterView
---
.../CodeEditTextView/Gutter/GutterView.swift | 103 ++++++++++++++++++
.../TextLayoutManager/TextLayoutManager.swift | 15 ++-
.../TextView/TextView/TextView.swift | 7 +-
.../TextView/TextViewController.swift | 30 ++++-
4 files changed, 146 insertions(+), 9 deletions(-)
create mode 100644 Sources/CodeEditTextView/Gutter/GutterView.swift
diff --git a/Sources/CodeEditTextView/Gutter/GutterView.swift b/Sources/CodeEditTextView/Gutter/GutterView.swift
new file mode 100644
index 000000000..0188eaf96
--- /dev/null
+++ b/Sources/CodeEditTextView/Gutter/GutterView.swift
@@ -0,0 +1,103 @@
+//
+// GutterView.swift
+//
+//
+// Created by Khan Winter on 8/22/23.
+//
+
+import AppKit
+
+protocol GutterViewDelegate: AnyObject {
+ func gutterView(updatedWidth: CGFloat)
+ func gutterViewVisibleRect() -> CGRect
+}
+
+class GutterView: NSView {
+ @Invalidating(.display)
+ var textColor: NSColor = .secondaryLabelColor
+
+ @Invalidating(.display)
+ var font: NSFont = .systemFont(ofSize: 13)
+
+ weak var delegate: GutterViewDelegate?
+ weak var layoutManager: TextLayoutManager?
+
+ private var maxWidth: CGFloat = 0
+ private var maxLineCount: Int = 0
+
+ override var isFlipped: Bool {
+ true
+ }
+
+ init(font: NSFont, textColor: NSColor, delegate: GutterViewDelegate?, layoutManager: TextLayoutManager?) {
+ self.font = font
+ self.textColor = textColor
+ self.delegate = delegate
+ self.layoutManager = layoutManager
+
+ super.init(frame: .zero)
+// wantsLayer = true
+// autoresizingMask = [.width, .height]
+ wantsLayer = true
+ layerContentsRedrawPolicy = .onSetNeedsDisplay
+ translatesAutoresizingMaskIntoConstraints = false
+
+// layer?.backgroundColor = NSColor.red.cgColor
+// draw(frame)
+ }
+
+ override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func draw(_ dirtyRect: NSRect) {
+ guard let context = NSGraphicsContext.current?.cgContext,
+ let layoutManager,
+ let delegate else {
+ return
+ }
+ let originalMaxWidth = maxWidth
+ let attributes: [NSAttributedString.Key: Any] = [
+ .font: font,
+ .foregroundColor: textColor
+ ]
+ context.saveGState()
+ context.textMatrix = CGAffineTransform(scaleX: 1, y: -1)
+ for linePosition in layoutManager.visibleLines() {
+ let ctLine = CTLineCreateWithAttributedString(
+ NSAttributedString(string: "\(linePosition.index + 1)", attributes: attributes)
+ )
+ let fragment: LineFragment? = linePosition.data.typesetter.lineFragments.first?.data
+ let topDistance: CGFloat = ((fragment?.scaledHeight ?? 0) - (fragment?.height ?? 0))/2.0
+
+ context.textPosition = CGPoint(
+ x: 0,
+ y: linePosition.yPos - delegate.gutterViewVisibleRect().origin.y
+ + (fragment?.height ?? 0)
+ - topDistance
+ )
+ CTLineDraw(ctLine, context)
+
+ maxWidth = max(CTLineGetBoundsWithOptions(ctLine, CTLineBoundsOptions()).width, maxWidth)
+ }
+ context.restoreGState()
+
+ if maxLineCount < layoutManager.lineStorage.count {
+ let maxCtLine = CTLineCreateWithAttributedString(
+ NSAttributedString(string: "\(layoutManager.lineStorage.count)", attributes: attributes)
+ )
+ let bounds = CTLineGetBoundsWithOptions(maxCtLine, CTLineBoundsOptions())
+ maxWidth = max(maxWidth, bounds.width)
+ maxLineCount = layoutManager.lineStorage.count
+ }
+
+ if originalMaxWidth != maxWidth {
+ self.frame.size.width = maxWidth
+ delegate.gutterView(updatedWidth: maxWidth)
+ }
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
index 35b10a9c2..0c76fb651 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -26,6 +26,11 @@ final class TextLayoutManager: NSObject {
public var lineHeightMultiplier: CGFloat
public var wrapLines: Bool
public var detectedLineEnding: LineEnding = .lf
+ public var gutterWidth: CGFloat = 20 {
+ didSet {
+ setNeedsLayout()
+ }
+ }
// MARK: - Internal
@@ -124,7 +129,7 @@ final class TextLayoutManager: NSObject {
}
let fragment = fragmentPosition.data
- if fragment.width < point.x {
+ if fragment.width < point.x - gutterWidth {
let fragmentRange = CTLineGetStringRange(fragment.ctLine)
// Return eol
return position.range.location + fragmentRange.location + fragmentRange.length - (
@@ -136,7 +141,7 @@ final class TextLayoutManager: NSObject {
// Somewhere in the fragment
let fragmentIndex = CTLineGetStringIndexForPosition(
fragment.ctLine,
- CGPoint(x: point.x, y: fragment.height/2)
+ CGPoint(x: point.x - gutterWidth, y: fragment.height/2)
)
return position.range.location + fragmentIndex
}
@@ -161,7 +166,7 @@ final class TextLayoutManager: NSObject {
)
return CGPoint(
- x: xPos,
+ x: xPos + gutterWidth,
y: linePosition.yPos + fragmentPosition.yPos
+ (fragmentPosition.data.height - fragmentPosition.data.scaledHeight)/2
)
@@ -204,7 +209,7 @@ final class TextLayoutManager: NSObject {
var usedFragmentIDs = Set()
var forceLayout: Bool = needsLayout
let maxWidth: CGFloat = wrapLines
- ? delegate?.textViewSize().width ?? .greatestFiniteMagnitude
+ ? (delegate?.textViewSize().width ?? .greatestFiniteMagnitude) - gutterWidth
: .greatestFiniteMagnitude
var newVisibleLines: Set = []
var yContentAdjustment: CGFloat = 0
@@ -310,7 +315,7 @@ final class TextLayoutManager: NSObject {
) {
let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id)
view.setLineFragment(lineFragment.data)
- view.frame.origin = CGPoint(x: 0, y: yPos)
+ view.frame.origin = CGPoint(x: gutterWidth, y: yPos)
layoutView?.addSubview(view)
view.needsDisplay = true
}
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
index 42a105f19..f36d9fcbd 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
@@ -88,7 +88,7 @@ class TextView: NSView, NSTextContent {
super.init(frame: .zero)
wantsLayer = true
- canDrawSubviewsIntoLayer = true
+// canDrawSubviewsIntoLayer = true
postsFrameChangedNotifications = true
postsBoundsChangedNotifications = true
autoresizingMask = [.width, .height]
@@ -233,6 +233,11 @@ class TextView: NSView, NSTextContent {
)
}
+ public func updatedViewport(_ newRect: CGRect) {
+ layoutManager.layoutLines()
+ inputContext?.invalidateCharacterCoordinates()
+ }
+
public func updateFrameIfNeeded() {
var availableSize = scrollView?.contentSize ?? .zero
availableSize.height -= (scrollView?.contentInsets.top ?? 0) + (scrollView?.contentInsets.bottom ?? 0)
diff --git a/Sources/CodeEditTextView/TextView/TextViewController.swift b/Sources/CodeEditTextView/TextView/TextViewController.swift
index 02b52e036..65b5246f0 100644
--- a/Sources/CodeEditTextView/TextView/TextViewController.swift
+++ b/Sources/CodeEditTextView/TextView/TextViewController.swift
@@ -13,6 +13,7 @@ import SwiftTreeSitter
public class TextViewController: NSViewController {
var scrollView: NSScrollView!
var textView: TextView!
+ var gutterView: GutterView!
public var string: Binding
public var language: CodeLanguage
@@ -99,6 +100,13 @@ public class TextViewController: NSViewController {
scrollView.contentInsets = contentInsets
}
+ self.gutterView = GutterView(font: font.rulerFont, textColor: .secondaryLabelColor, delegate: self, layoutManager: textView.layoutManager)
+ gutterView.frame.size.width = 30
+ scrollView.addFloatingSubview(
+ gutterView,
+ for: .vertical
+ )
+
self.view = scrollView
setUpHighlighter()
@@ -107,7 +115,8 @@ public class TextViewController: NSViewController {
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
- scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
+ scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ gutterView.heightAnchor.constraint(equalTo: scrollView.contentView.heightAnchor, multiplier: 1.0)
])
// Layout on scroll change
@@ -116,8 +125,8 @@ public class TextViewController: NSViewController {
object: scrollView.contentView,
queue: .main
) { _ in
- self.textView.layoutManager.layoutLines()
- self.textView.inputContext?.invalidateCharacterCoordinates()
+ self.textView.updatedViewport(self.scrollView.documentVisibleRect)
+ self.gutterView.needsDisplay = true
}
// Layout on frame change
@@ -127,9 +136,11 @@ public class TextViewController: NSViewController {
queue: .main
) { _ in
self.textView.layoutManager.layoutLines()
+ self.gutterView.needsDisplay = true
}
textView.updateFrameIfNeeded()
+ gutterView.needsDisplay = true
}
}
@@ -177,3 +188,16 @@ extension TextViewController {
}
}
}
+
+extension TextViewController: GutterViewDelegate {
+ func gutterViewVisibleRect() -> CGRect {
+ var rect = scrollView.documentVisibleRect
+ rect.origin.y += scrollView.contentInsets.top
+ rect.size.height -= scrollView.contentInsets.top + scrollView.contentInsets.bottom
+ return rect
+ }
+
+ func gutterView(updatedWidth: CGFloat) {
+ textView.layoutManager.gutterWidth = updatedWidth
+ }
+}
From 19500f97d0e2e3e9ff3a4458f52c82a17d9ee58a Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Wed, 23 Aug 2023 09:53:28 -0500
Subject: [PATCH 20/75] Line Numbers final, fix insert bug
---
.../CodeEditTextView/Gutter/GutterView.swift | 144 +++++++++++++-----
.../TextLayoutManager/TextLayoutManager.swift | 20 ++-
.../TextSelectionManager.swift | 10 +-
.../TextView/TextView+NSTextInput.swift | 3 +-
.../TextView/TextView/TextView.swift | 11 +-
.../TextView/TextViewController.swift | 39 ++---
TODO.md | 8 +-
7 files changed, 160 insertions(+), 75 deletions(-)
diff --git a/Sources/CodeEditTextView/Gutter/GutterView.swift b/Sources/CodeEditTextView/Gutter/GutterView.swift
index 0188eaf96..2ed8f3141 100644
--- a/Sources/CodeEditTextView/Gutter/GutterView.swift
+++ b/Sources/CodeEditTextView/Gutter/GutterView.swift
@@ -7,43 +7,65 @@
import AppKit
-protocol GutterViewDelegate: AnyObject {
- func gutterView(updatedWidth: CGFloat)
- func gutterViewVisibleRect() -> CGRect
-}
-
class GutterView: NSView {
+ struct EdgeInsets: Equatable, Hashable {
+ let leading: CGFloat
+ let trailing: CGFloat
+
+ var horizontal: CGFloat {
+ leading + trailing
+ }
+ }
+
@Invalidating(.display)
var textColor: NSColor = .secondaryLabelColor
@Invalidating(.display)
var font: NSFont = .systemFont(ofSize: 13)
- weak var delegate: GutterViewDelegate?
- weak var layoutManager: TextLayoutManager?
+ @Invalidating(.display)
+ var edgeInsets: EdgeInsets = EdgeInsets(leading: 20, trailing: 12)
+
+ @Invalidating(.display)
+ var backgroundColor: NSColor? = NSColor.controlBackgroundColor
+
+ @Invalidating(.display)
+ var highlightSelectedLines: Bool = true
+
+ @Invalidating(.display)
+ var selectedLineColor: NSColor = NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled)
+ private weak var textView: TextView?
private var maxWidth: CGFloat = 0
- private var maxLineCount: Int = 0
+ /// The maximum number of digits found for a line number.
+ private var maxLineLength: Int = 0
override var isFlipped: Bool {
true
}
- init(font: NSFont, textColor: NSColor, delegate: GutterViewDelegate?, layoutManager: TextLayoutManager?) {
+ init(
+ font: NSFont,
+ textColor: NSColor,
+ textView: TextView
+ ) {
self.font = font
self.textColor = textColor
- self.delegate = delegate
- self.layoutManager = layoutManager
+ self.textView = textView
super.init(frame: .zero)
-// wantsLayer = true
-// autoresizingMask = [.width, .height]
wantsLayer = true
layerContentsRedrawPolicy = .onSetNeedsDisplay
translatesAutoresizingMaskIntoConstraints = false
-
-// layer?.backgroundColor = NSColor.red.cgColor
-// draw(frame)
+ layer?.masksToBounds = false
+
+ NotificationCenter.default.addObserver(
+ forName: TextSelectionManager.selectionChangedNotification,
+ object: nil,
+ queue: .main
+ ) { _ in
+ self.needsDisplay = true
+ }
}
override init(frame frameRect: NSRect) {
@@ -54,50 +76,92 @@ class GutterView: NSView {
fatalError("init(coder:) has not been implemented")
}
- override func draw(_ dirtyRect: NSRect) {
- guard let context = NSGraphicsContext.current?.cgContext,
- let layoutManager,
- let delegate else {
+ /// Updates the width of the gutter if needed.
+ func updateWidthIfNeeded() {
+ guard let textView else { return }
+ let attributes: [NSAttributedString.Key: Any] = [
+ .font: font,
+ .foregroundColor: textColor
+ ]
+ let originalMaxWidth = maxWidth
+ // Reserve at least 3 digits of space no matter what
+ let lineStorageDigits = max(3, String(textView.layoutManager.lineStorage.count).count)
+
+ if maxLineLength < lineStorageDigits {
+ // Update the max width
+ let maxCtLine = CTLineCreateWithAttributedString(
+ NSAttributedString(string: String(repeating: "0", count: lineStorageDigits), attributes: attributes)
+ )
+ let bounds = CTLineGetBoundsWithOptions(maxCtLine, CTLineBoundsOptions())
+ maxWidth = max(maxWidth, bounds.width)
+ maxLineLength = lineStorageDigits
+ }
+
+ if originalMaxWidth != maxWidth {
+ self.frame.size.width = maxWidth + edgeInsets.horizontal
+ textView.layoutManager.gutterWidth = maxWidth + edgeInsets.horizontal
+ }
+ }
+
+ private func drawSelectedLines(_ context: CGContext) {
+ guard let textView = textView,
+ let selectionManager = textView.selectionManager,
+ let visibleRange = textView.visibleTextRange,
+ highlightSelectedLines else {
return
}
- let originalMaxWidth = maxWidth
+ context.saveGState()
+ context.setFillColor(selectedLineColor.cgColor)
+ for selection in selectionManager.textSelections {
+ guard let line = textView.layoutManager.textLineForOffset(selection.range.location),
+ visibleRange.intersection(line.range) != nil else {
+ continue
+ }
+ context.fill(CGRect(x: 0.0, y: line.yPos, width: maxWidth + edgeInsets.horizontal, height: line.height))
+ }
+ context.restoreGState()
+ }
+
+ private func drawLineNumbers(_ context: CGContext) {
+ guard let textView = textView else { return }
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: textColor
]
+
context.saveGState()
context.textMatrix = CGAffineTransform(scaleX: 1, y: -1)
- for linePosition in layoutManager.visibleLines() {
+ for linePosition in textView.layoutManager.visibleLines() {
let ctLine = CTLineCreateWithAttributedString(
NSAttributedString(string: "\(linePosition.index + 1)", attributes: attributes)
)
let fragment: LineFragment? = linePosition.data.typesetter.lineFragments.first?.data
- let topDistance: CGFloat = ((fragment?.scaledHeight ?? 0) - (fragment?.height ?? 0))/2.0
+
+ let topPadding: CGFloat = ((fragment?.scaledHeight ?? 0) - (fragment?.height ?? 0))/2.0
+ let yPos = linePosition.yPos + (fragment?.height ?? 0) - topPadding
+ // Leading padding + (width - linewidth)
+ let xPos = edgeInsets.leading + (maxWidth - CTLineGetBoundsWithOptions(ctLine, CTLineBoundsOptions()).width)
context.textPosition = CGPoint(
- x: 0,
- y: linePosition.yPos - delegate.gutterViewVisibleRect().origin.y
- + (fragment?.height ?? 0)
- - topDistance
+ x: xPos,
+ y: yPos
)
CTLineDraw(ctLine, context)
-
- maxWidth = max(CTLineGetBoundsWithOptions(ctLine, CTLineBoundsOptions()).width, maxWidth)
}
context.restoreGState()
+ }
- if maxLineCount < layoutManager.lineStorage.count {
- let maxCtLine = CTLineCreateWithAttributedString(
- NSAttributedString(string: "\(layoutManager.lineStorage.count)", attributes: attributes)
- )
- let bounds = CTLineGetBoundsWithOptions(maxCtLine, CTLineBoundsOptions())
- maxWidth = max(maxWidth, bounds.width)
- maxLineCount = layoutManager.lineStorage.count
+ override func draw(_ dirtyRect: NSRect) {
+ guard let context = NSGraphicsContext.current?.cgContext else {
+ return
}
+ layer?.backgroundColor = backgroundColor?.cgColor
+ updateWidthIfNeeded()
+ drawSelectedLines(context)
+ drawLineNumbers(context)
+ }
- if originalMaxWidth != maxWidth {
- self.frame.size.width = maxWidth
- delegate.gutterView(updatedWidth: maxWidth)
- }
+ deinit {
+ NotificationCenter.default.removeObserver(self)
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
index 0c76fb651..573f608b1 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -40,6 +40,7 @@ final class TextLayoutManager: NSObject {
private var visibleLineIds: Set = []
/// Used to force a complete re-layout using `setNeedsLayout`
private var needsLayout: Bool = false
+ private var isInTransaction: Bool = false
weak private var layoutView: NSView?
@@ -120,6 +121,10 @@ final class TextLayoutManager: NSObject {
lineStorage.getLine(atPosition: posY)
}
+ public func textLineForOffset(_ offset: Int) -> TextLineStorage.TextLinePosition? {
+ lineStorage.getLine(atIndex: offset)
+ }
+
public func textOffsetAtPoint(_ point: CGPoint) -> Int? {
guard let position = lineStorage.getLine(atPosition: point.y),
let fragmentPosition = position.data.typesetter.lineFragments.getLine(
@@ -198,11 +203,21 @@ final class TextLayoutManager: NSObject {
visibleLineIds.removeAll(keepingCapacity: true)
}
+ func beginTransaction() {
+ isInTransaction = true
+ }
+
+ func endTransaction() {
+ isInTransaction = false
+ setNeedsLayout()
+ layoutLines()
+ }
+
// MARK: - Layout
/// Lays out all visible lines
internal func layoutLines() {
- guard let visibleRect = delegate?.visibleRect else { return }
+ guard let visibleRect = delegate?.visibleRect, !isInTransaction else { return }
let minY = max(visibleRect.minY, 0)
let maxY = max(visibleRect.maxY, 0)
let originalHeight = lineStorage.height
@@ -274,7 +289,7 @@ final class TextLayoutManager: NSObject {
/// - maxY: The maximum Y value to end layout at.
/// - laidOutFragmentIDs: Updated by this method as line fragments are laid out.
/// - Returns: A `CGSize` representing the max width and total height of the laid out portion of the line.
- internal func layoutLine(
+ private func layoutLine(
_ position: TextLineStorage.TextLinePosition,
minY: CGFloat,
maxY: CGFloat,
@@ -291,6 +306,7 @@ final class TextLayoutManager: NSObject {
var height: CGFloat = 0
var width: CGFloat = 0
+
// TODO: Lay out only fragments in min/max Y
for lineFragmentPosition in line.typesetter.lineFragments {
let lineFragment = lineFragmentPosition.data
diff --git a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
index f531d961d..a1908e351 100644
--- a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
@@ -42,6 +42,10 @@ class TextSelectionManager {
}
}
+ class var selectionChangedNotification: Notification.Name {
+ Notification.Name("TextSelectionManager.TextSelectionChangedNotification")
+ }
+
private(set) var markedText: [MarkedText] = []
private(set) var textSelections: [TextSelection] = []
private unowned var layoutManager: TextLayoutManager
@@ -50,10 +54,7 @@ class TextSelectionManager {
init(layoutManager: TextLayoutManager, delegate: TextSelectionManagerDelegate?) {
self.layoutManager = layoutManager
self.delegate = delegate
- textSelections = [
- .init(range: NSRange(location: 0, length: 4)),
- .init(range: NSRange(location: 6, length: 10))
- ]
+ textSelections = []
updateSelectionViews()
}
@@ -83,5 +84,6 @@ class TextSelectionManager {
// TODO: Selection Highlights
}
}
+ NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification))
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift b/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
index 7325f7ed5..854adc5d5 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
@@ -29,7 +29,7 @@ import TextStory
extension TextView: NSTextInputClient {
@objc public func insertText(_ string: Any, replacementRange: NSRange) {
guard isEditable else { return }
- layoutManager.setNeedsLayout() // force a complete layout when the storage edit is committed.
+ layoutManager.beginTransaction()
textStorage.beginEditing()
selectionManager?.textSelections.forEach { selection in
switch string {
@@ -50,6 +50,7 @@ extension TextView: NSTextInputClient {
}
}
textStorage.endEditing()
+ layoutManager.endTransaction()
selectionManager?.updateSelectionViews()
}
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
index f36d9fcbd..9868d7e76 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
@@ -88,11 +88,13 @@ class TextView: NSView, NSTextContent {
super.init(frame: .zero)
wantsLayer = true
-// canDrawSubviewsIntoLayer = true
postsFrameChangedNotifications = true
postsBoundsChangedNotifications = true
autoresizingMask = [.width, .height]
+ // TODO: Implement typing/"default" attributes
+ textStorage.addAttributes([.font: font], range: documentRange)
+
self.layoutManager = TextLayoutManager(
textStorage: textStorage,
typingAttributes: [
@@ -106,13 +108,6 @@ class TextView: NSView, NSTextContent {
textStorage.delegate = storageDelegate
storageDelegate.addDelegate(layoutManager)
- textStorage.addAttributes(
- [
- .font: font
- ],
- range: documentRange
- )
-
layoutManager.layoutLines()
if isSelectable {
diff --git a/Sources/CodeEditTextView/TextView/TextViewController.swift b/Sources/CodeEditTextView/TextView/TextViewController.swift
index 65b5246f0..5d3ac12ad 100644
--- a/Sources/CodeEditTextView/TextView/TextViewController.swift
+++ b/Sources/CodeEditTextView/TextView/TextViewController.swift
@@ -75,6 +75,7 @@ public class TextViewController: NSViewController {
fatalError("init(coder:) has not been implemented")
}
+ // swiftlint:disable:next function_body_length
public override func loadView() {
scrollView = NSScrollView()
textView = TextView(
@@ -87,6 +88,7 @@ public class TextViewController: NSViewController {
letterSpacing: letterSpacing,
storageDelegate: storageDelegate
)
+ textView.postsFrameChangedNotifications = true
textView.translatesAutoresizingMaskIntoConstraints = false
scrollView.translatesAutoresizingMaskIntoConstraints = false
@@ -100,11 +102,12 @@ public class TextViewController: NSViewController {
scrollView.contentInsets = contentInsets
}
- self.gutterView = GutterView(font: font.rulerFont, textColor: .secondaryLabelColor, delegate: self, layoutManager: textView.layoutManager)
- gutterView.frame.size.width = 30
+ gutterView = GutterView(font: font.rulerFont, textColor: .secondaryLabelColor, textView: textView)
+ gutterView.frame.origin.y = -scrollView.contentInsets.top
+ gutterView.updateWidthIfNeeded()
scrollView.addFloatingSubview(
gutterView,
- for: .vertical
+ for: .horizontal
)
self.view = scrollView
@@ -115,8 +118,7 @@ public class TextViewController: NSViewController {
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
- scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
- gutterView.heightAnchor.constraint(equalTo: scrollView.contentView.heightAnchor, multiplier: 1.0)
+ scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
// Layout on scroll change
@@ -139,8 +141,20 @@ public class TextViewController: NSViewController {
self.gutterView.needsDisplay = true
}
+ NotificationCenter.default.addObserver(
+ forName: NSView.frameDidChangeNotification,
+ object: textView,
+ queue: .main
+ ) { _ in
+ self.gutterView.frame.size.height = self.textView.frame.height
+ self.gutterView.needsDisplay = true
+ }
+
textView.updateFrameIfNeeded()
- gutterView.needsDisplay = true
+ }
+
+ deinit {
+ NotificationCenter.default.removeObserver(self)
}
}
@@ -188,16 +202,3 @@ extension TextViewController {
}
}
}
-
-extension TextViewController: GutterViewDelegate {
- func gutterViewVisibleRect() -> CGRect {
- var rect = scrollView.documentVisibleRect
- rect.origin.y += scrollView.contentInsets.top
- rect.size.height -= scrollView.contentInsets.top + scrollView.contentInsets.bottom
- return rect
- }
-
- func gutterView(updatedWidth: CGFloat) {
- textView.layoutManager.gutterWidth = updatedWidth
- }
-}
diff --git a/TODO.md b/TODO.md
index 7f1e26bbb..ed6a3a3fe 100644
--- a/TODO.md
+++ b/TODO.md
@@ -14,9 +14,15 @@
- [x] isEditable
- [x] Insert
- [] Delete
- - [] Copy/paste
+ - [x] Paste
+- [x] Line Numbers
- [] select text
+ - [] Copy
- [] multiple cursors (+ edit)
+- [] Keyboard navigation
+ - [] Arrow keys
+ - [] Command & control arrow keys
+ - [] Page up and down
- [] tab widths & indents
- [] paramater updating
- [] tab & indent options
From 1f1684122ef33f261c6e49937f933da2f2f7c569 Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Wed, 23 Aug 2023 11:44:13 -0500
Subject: [PATCH 21/75] Selected line highlights, remove strong references
---
.../Extensions/NSRange+/NSRange+isEmpty.swift | 14 ++++
.../Highlighting/Highlighter.swift | 6 +-
.../TextLayoutManager/LineFragmentView.swift | 12 +--
.../TextLayoutManager/TextLayoutManager.swift | 5 +-
.../TextSelectionManager/CursorView.swift | 5 +-
.../TextSelectionManager.swift | 83 +++++++++++++++----
.../TextView/TextView/TextView.swift | 61 +++++++++-----
.../TextView/TextViewController.swift | 21 +++--
.../Theme/ThemeAttributesProviding.swift | 2 +-
9 files changed, 148 insertions(+), 61 deletions(-)
create mode 100644 Sources/CodeEditTextView/Extensions/NSRange+/NSRange+isEmpty.swift
diff --git a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+isEmpty.swift b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+isEmpty.swift
new file mode 100644
index 000000000..36af2cb54
--- /dev/null
+++ b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+isEmpty.swift
@@ -0,0 +1,14 @@
+//
+// NSRange+isEmpty.swift
+//
+//
+// Created by Khan Winter on 8/23/23.
+//
+
+import Foundation
+
+extension NSRange {
+ var isEmpty: Bool {
+ length == 0
+ }
+}
diff --git a/Sources/CodeEditTextView/Highlighting/Highlighter.swift b/Sources/CodeEditTextView/Highlighting/Highlighter.swift
index aa38a5b0a..22a860845 100644
--- a/Sources/CodeEditTextView/Highlighting/Highlighter.swift
+++ b/Sources/CodeEditTextView/Highlighting/Highlighter.swift
@@ -43,19 +43,19 @@ class Highlighter: NSObject {
// MARK: - UI
/// The text view to highlight
- private var textView: TextView
+ private unowned var textView: TextView
/// The editor theme
private var theme: EditorTheme
/// The object providing attributes for captures.
- private var attributeProvider: ThemeAttributesProviding!
+ private weak var attributeProvider: ThemeAttributesProviding!
/// The current language of the editor.
private var language: CodeLanguage
/// Calculates invalidated ranges given an edit.
- private var highlightProvider: HighlightProviding?
+ private weak var highlightProvider: HighlightProviding?
/// The length to chunk ranges into when passing to the highlighter.
fileprivate let rangeChunkLimit = 256
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentView.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentView.swift
index 3ab083ed5..fff67d9a9 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentView.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentView.swift
@@ -25,16 +25,16 @@ final class LineFragmentView: NSView {
}
override func draw(_ dirtyRect: NSRect) {
- guard let lineFragment, let ctx = NSGraphicsContext.current?.cgContext else {
+ guard let lineFragment, let context = NSGraphicsContext.current?.cgContext else {
return
}
- ctx.saveGState()
- ctx.textMatrix = .init(scaleX: 1, y: -1)
- ctx.textPosition = CGPoint(
+ context.saveGState()
+ context.textMatrix = .init(scaleX: 1, y: -1)
+ context.textPosition = CGPoint(
x: 0,
y: lineFragment.height + ((lineFragment.height - lineFragment.scaledHeight) / 2)
)
- CTLineDraw(lineFragment.ctLine, ctx)
- ctx.restoreGState()
+ CTLineDraw(lineFragment.ctLine, context)
+ context.restoreGState()
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
index 573f608b1..7759f0cee 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -96,7 +96,7 @@ final class TextLayoutManager: NSObject {
#endif
}
- private func estimateLineHeight() -> CGFloat {
+ internal func estimateLineHeight() -> CGFloat {
let string = NSAttributedString(string: "0", attributes: typingAttributes)
let typesetter = CTTypesetterCreateWithAttributedString(string)
let ctLine = CTTypesetterCreateLine(typesetter, CFRangeMake(0, 1))
@@ -156,7 +156,7 @@ final class TextLayoutManager: NSObject {
/// Returns the bottom-left corner of the character.
/// - Parameter offset: The offset to create the rect for.
/// - Returns: The found rect for the given offset.
- public func positionForOffset(_ offset: Int) -> CGPoint? {
+ public func pointForOffset(_ offset: Int) -> CGPoint? {
guard let linePosition = lineStorage.getLine(atIndex: offset),
let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine(
atIndex: offset - linePosition.range.location
@@ -306,7 +306,6 @@ final class TextLayoutManager: NSObject {
var height: CGFloat = 0
var width: CGFloat = 0
-
// TODO: Lay out only fragments in min/max Y
for lineFragmentPosition in line.typesetter.lineFragments {
let lineFragment = lineFragmentPosition.data
diff --git a/Sources/CodeEditTextView/TextView/TextSelectionManager/CursorView.swift b/Sources/CodeEditTextView/TextView/TextSelectionManager/CursorView.swift
index fcf5836bc..4f0e3acca 100644
--- a/Sources/CodeEditTextView/TextView/TextSelectionManager/CursorView.swift
+++ b/Sources/CodeEditTextView/TextView/TextSelectionManager/CursorView.swift
@@ -40,8 +40,8 @@ class CursorView: NSView {
layer?.backgroundColor = color
if let blinkDuration {
- timer = Timer.scheduledTimer(withTimeInterval: blinkDuration, repeats: true, block: { _ in
- self.isHidden.toggle()
+ timer = Timer.scheduledTimer(withTimeInterval: blinkDuration, repeats: true, block: { [weak self] _ in
+ self?.isHidden.toggle()
})
}
}
@@ -52,5 +52,6 @@ class CursorView: NSView {
deinit {
timer?.invalidate()
+ timer = nil
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
index a1908e351..86b937ac5 100644
--- a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
@@ -9,14 +9,15 @@ import AppKit
protocol TextSelectionManagerDelegate: AnyObject {
var font: NSFont { get }
- var lineHeight: CGFloat { get }
- func addCursorView(_ view: NSView)
+ func setNeedsDisplay()
+ func estimatedLineHeight() -> CGFloat
}
/// Manages an array of text selections representing cursors (0-length ranges) and selections (>0-length ranges).
///
-/// Draws selections using a draw method similar to the `TextLayoutManager` class, and adds
+/// Draws selections using a draw method similar to the `TextLayoutManager` class, and adds cursor views when
+/// appropriate.
class TextSelectionManager {
struct MarkedText {
let range: NSRange
@@ -46,13 +47,17 @@ class TextSelectionManager {
Notification.Name("TextSelectionManager.TextSelectionChangedNotification")
}
+ public var selectedLineBackgroundColor: NSColor = NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled)
+
private(set) var markedText: [MarkedText] = []
private(set) var textSelections: [TextSelection] = []
- private unowned var layoutManager: TextLayoutManager
+ private weak var layoutManager: TextLayoutManager?
+ weak private var layoutView: NSView?
private weak var delegate: TextSelectionManagerDelegate?
- init(layoutManager: TextLayoutManager, delegate: TextSelectionManagerDelegate?) {
+ init(layoutManager: TextLayoutManager, layoutView: NSView?, delegate: TextSelectionManagerDelegate?) {
self.layoutManager = layoutManager
+ self.layoutView = layoutView
self.delegate = delegate
textSelections = []
updateSelectionViews()
@@ -72,18 +77,68 @@ class TextSelectionManager {
internal func updateSelectionViews() {
textSelections.forEach { $0.view?.removeFromSuperview() }
+ for textSelection in textSelections where textSelection.range.isEmpty {
+ textSelection.view?.removeFromSuperview()
+ let lineFragment = layoutManager?
+ .textLineForOffset(textSelection.range.location)?
+ .data
+ .typesetter
+ .lineFragments
+ .first
+
+ let cursorView = CursorView()
+ cursorView.frame.origin = layoutManager?.pointForOffset(textSelection.range.location) ?? .zero
+
+ cursorView.frame.size.height = lineFragment?.data.scaledHeight ?? 0
+ layoutView?.addSubview(cursorView)
+ textSelection.view = cursorView
+ }
+ delegate?.setNeedsDisplay()
+ NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification))
+ }
+
+ /// Draws line backgrounds and selection rects for each selection in the given rect.
+ /// - Parameter rect: The rect to draw in.
+ internal func drawSelections(in rect: NSRect) {
+ guard let context = NSGraphicsContext.current?.cgContext else { return }
+ context.saveGState()
+ // For each selection in the rect
for textSelection in textSelections {
- if textSelection.range.length == 0 {
- textSelection.view?.removeFromSuperview()
- let selectionView = CursorView()
- selectionView.frame.origin = layoutManager.positionForOffset(textSelection.range.location) ?? .zero
- selectionView.frame.size.height = (delegate?.font.lineHeight ?? 0) * (delegate?.lineHeight ?? 0)
- delegate?.addCursorView(selectionView)
- textSelection.view = selectionView
+ if textSelection.range.isEmpty {
+ // Highlight the line
+ guard let linePosition = layoutManager?.textLineForOffset(textSelection.range.location) else {
+ continue
+ }
+ context.setFillColor(selectedLineBackgroundColor.cgColor)
+ context.fill(
+ CGRect(
+ x: rect.minX,
+ y: linePosition.yPos,
+ width: rect.width,
+ height: linePosition.height
+ )
+ )
} else {
- // TODO: Selection Highlights
+ // TODO: Highlight Selection Ranges
+
+// guard let selectionPointMin = layoutManager.pointForOffset(selection.range.location),
+// let selectionPointMax = layoutManager.pointForOffset(selection.range.max) else {
+// continue
+// }
+// let selectionRect = NSRect(
+// x: selectionPointMin.x,
+// y: selectionPointMin.y,
+// width: selectionPointMax.x - selectionPointMin.x,
+// height: selectionPointMax.y - selectionPointMin.y
+// )
+// if selectionRect.intersects(rect) {
+// // This selection has some portion in the visible rect, draw it.
+// for linePosition in layoutManager.lineStorage.linesInRange(selection.range) {
+//
+// }
+// }
}
}
- NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification))
+ context.restoreGState()
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
index 9868d7e76..c87f7f6ac 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
@@ -34,15 +34,8 @@ class TextView: NSView, NSTextContent {
public var wrapLines: Bool
public var editorOverscroll: CGFloat
public var isEditable: Bool
- public var isSelectable: Bool = true {
- didSet {
- if isSelectable {
- self.selectionManager = TextSelectionManager(layoutManager: layoutManager, delegate: self)
- } else {
- self.selectionManager = nil
- }
- }
- }
+ @Invalidating(.display)
+ public var isSelectable: Bool = true
public var letterSpacing: Double
open var contentType: NSTextContentType?
@@ -51,7 +44,7 @@ class TextView: NSView, NSTextContent {
private(set) var textStorage: NSTextStorage!
private(set) var layoutManager: TextLayoutManager!
- private(set) var selectionManager: TextSelectionManager?
+ private(set) var selectionManager: TextSelectionManager!
internal var isFirstResponder: Bool = false
@@ -95,7 +88,7 @@ class TextView: NSView, NSTextContent {
// TODO: Implement typing/"default" attributes
textStorage.addAttributes([.font: font], range: documentRange)
- self.layoutManager = TextLayoutManager(
+ layoutManager = TextLayoutManager(
textStorage: textStorage,
typingAttributes: [
.font: font,
@@ -108,13 +101,15 @@ class TextView: NSView, NSTextContent {
textStorage.delegate = storageDelegate
storageDelegate.addDelegate(layoutManager)
- layoutManager.layoutLines()
-
- if isSelectable {
- self.selectionManager = TextSelectionManager(layoutManager: layoutManager, delegate: self)
- }
+ selectionManager = TextSelectionManager(
+ layoutManager: layoutManager,
+ layoutView: self, // TODO: This is an odd syntax... consider reworking this
+ delegate: self
+ )
_undoManager = CEUndoManager(textView: self)
+
+ layoutManager.layoutLines()
}
required init?(coder: NSCoder) {
@@ -191,7 +186,9 @@ class TextView: NSView, NSTextContent {
super.mouseDown(with: event)
return
}
- selectionManager?.setSelectedRange(NSRange(location: offset, length: 0))
+ if isSelectable {
+ selectionManager.setSelectedRange(NSRange(location: offset, length: 0))
+ }
if !self.isFirstResponder {
self.window?.makeFirstResponder(self)
@@ -200,6 +197,13 @@ class TextView: NSView, NSTextContent {
// MARK: - Draw
+ override func draw(_ dirtyRect: NSRect) {
+ super.draw(dirtyRect)
+ if isSelectable {
+ selectionManager.drawSelections(in: dirtyRect)
+ }
+ }
+
override open var isFlipped: Bool {
true
}
@@ -229,11 +233,14 @@ class TextView: NSView, NSTextContent {
}
public func updatedViewport(_ newRect: CGRect) {
- layoutManager.layoutLines()
+ if !updateFrameIfNeeded() {
+ layoutManager.layoutLines()
+ }
inputContext?.invalidateCharacterCoordinates()
}
- public func updateFrameIfNeeded() {
+ @discardableResult
+ public func updateFrameIfNeeded() -> Bool {
var availableSize = scrollView?.contentSize ?? .zero
availableSize.height -= (scrollView?.contentInsets.top ?? 0) + (scrollView?.contentInsets.bottom ?? 0)
let newHeight = layoutManager.estimatedHeight()
@@ -249,7 +256,7 @@ class TextView: NSView, NSTextContent {
if wrapLines && frame.size.width != availableSize.width {
frame.size.width = availableSize.width
didUpdate = true
- } else if !wrapLines && newWidth > availableSize.width && frame.size.width != newWidth {
+ } else if !wrapLines && frame.size.width != max(newWidth, availableSize.width) {
frame.size.width = max(newWidth, availableSize.width)
didUpdate = true
}
@@ -260,7 +267,11 @@ class TextView: NSView, NSTextContent {
layoutManager.layoutLines()
}
- selectionManager?.updateSelectionViews()
+ if isSelectable {
+ selectionManager?.updateSelectionViews()
+ }
+
+ return didUpdate
}
deinit {
@@ -307,7 +318,11 @@ extension TextView: TextLayoutManagerDelegate {
// MARK: - TextSelectionManagerDelegate
extension TextView: TextSelectionManagerDelegate {
- func addCursorView(_ view: NSView) {
- addSubview(view)
+ func setNeedsDisplay() {
+ self.setNeedsDisplay(visibleRect)
+ }
+
+ func estimatedLineHeight() -> CGFloat {
+ layoutManager.estimateLineHeight()
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextViewController.swift b/Sources/CodeEditTextView/TextView/TextViewController.swift
index 5d3ac12ad..31c929f13 100644
--- a/Sources/CodeEditTextView/TextView/TextViewController.swift
+++ b/Sources/CodeEditTextView/TextView/TextViewController.swift
@@ -126,9 +126,9 @@ public class TextViewController: NSViewController {
forName: NSView.boundsDidChangeNotification,
object: scrollView.contentView,
queue: .main
- ) { _ in
- self.textView.updatedViewport(self.scrollView.documentVisibleRect)
- self.gutterView.needsDisplay = true
+ ) { [weak self] _ in
+ self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero)
+ self?.gutterView.needsDisplay = true
}
// Layout on frame change
@@ -136,24 +136,27 @@ public class TextViewController: NSViewController {
forName: NSView.frameDidChangeNotification,
object: scrollView.contentView,
queue: .main
- ) { _ in
- self.textView.layoutManager.layoutLines()
- self.gutterView.needsDisplay = true
+ ) { [weak self] _ in
+ self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero)
+ self?.gutterView.needsDisplay = true
}
NotificationCenter.default.addObserver(
forName: NSView.frameDidChangeNotification,
object: textView,
queue: .main
- ) { _ in
- self.gutterView.frame.size.height = self.textView.frame.height
- self.gutterView.needsDisplay = true
+ ) { [weak self] _ in
+ self?.gutterView.frame.size.height = self?.textView.frame.height ?? 0
+ self?.gutterView.needsDisplay = true
}
textView.updateFrameIfNeeded()
}
deinit {
+ highlighter = nil
+ highlightProvider = nil
+ storageDelegate = nil
NotificationCenter.default.removeObserver(self)
}
}
diff --git a/Sources/CodeEditTextView/Theme/ThemeAttributesProviding.swift b/Sources/CodeEditTextView/Theme/ThemeAttributesProviding.swift
index e7f335f9f..d1a32f884 100644
--- a/Sources/CodeEditTextView/Theme/ThemeAttributesProviding.swift
+++ b/Sources/CodeEditTextView/Theme/ThemeAttributesProviding.swift
@@ -8,6 +8,6 @@
import Foundation
/// Classes conforming to this protocol can provide attributes for text given a capture type.
-public protocol ThemeAttributesProviding {
+public protocol ThemeAttributesProviding: AnyObject {
func attributesFor(_ capture: CaptureName?) -> [NSAttributedString.Key: Any]
}
From eee21f9bc1102f240a1b7d960bf13db2c34965d8 Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Wed, 23 Aug 2023 12:58:23 -0500
Subject: [PATCH 22/75] Fix Cursor + Selection Background Misalignment
---
Sources/CodeEditTextView/CodeEditTextView.swift | 2 +-
Sources/CodeEditTextView/Gutter/GutterView.swift | 9 ++++++++-
.../TextLayoutManager/LineFragmentView.swift | 2 +-
.../TextView/TextSelectionManager/CursorView.swift | 12 ++++++------
.../TextSelectionManager/TextSelectionManager.swift | 10 ++++++++--
.../TextView/TextView/TextView.swift | 8 +++++++-
.../TextView/TextViewController.swift | 6 +++++-
7 files changed, 36 insertions(+), 13 deletions(-)
diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift
index ceadd4729..8ec17fc9a 100644
--- a/Sources/CodeEditTextView/CodeEditTextView.swift
+++ b/Sources/CodeEditTextView/CodeEditTextView.swift
@@ -118,7 +118,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
// return
// }
//
-// controller.font = font
+ controller.font = font
// controller.wrapLines = wrapLines
// controller.useThemeBackground = useThemeBackground
// controller.lineHeightMultiple = lineHeight
diff --git a/Sources/CodeEditTextView/Gutter/GutterView.swift b/Sources/CodeEditTextView/Gutter/GutterView.swift
index 2ed8f3141..d979654db 100644
--- a/Sources/CodeEditTextView/Gutter/GutterView.swift
+++ b/Sources/CodeEditTextView/Gutter/GutterView.swift
@@ -117,7 +117,14 @@ class GutterView: NSView {
visibleRange.intersection(line.range) != nil else {
continue
}
- context.fill(CGRect(x: 0.0, y: line.yPos, width: maxWidth + edgeInsets.horizontal, height: line.height))
+ context.fill(
+ CGRect(
+ x: 0.0,
+ y: textView.layoutManager.pointForOffset(line.range.location)?.y ?? line.yPos,
+ width: maxWidth + edgeInsets.horizontal,
+ height: line.height
+ )
+ )
}
context.restoreGState()
}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentView.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentView.swift
index fff67d9a9..f3773559b 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentView.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentView.swift
@@ -32,7 +32,7 @@ final class LineFragmentView: NSView {
context.textMatrix = .init(scaleX: 1, y: -1)
context.textPosition = CGPoint(
x: 0,
- y: lineFragment.height + ((lineFragment.height - lineFragment.scaledHeight) / 2)
+ y: lineFragment.height - ((lineFragment.scaledHeight - lineFragment.height)/2)
)
CTLineDraw(lineFragment.ctLine, context)
context.restoreGState()
diff --git a/Sources/CodeEditTextView/TextView/TextSelectionManager/CursorView.swift b/Sources/CodeEditTextView/TextView/TextSelectionManager/CursorView.swift
index 4f0e3acca..c3f486ecb 100644
--- a/Sources/CodeEditTextView/TextView/TextSelectionManager/CursorView.swift
+++ b/Sources/CodeEditTextView/TextView/TextSelectionManager/CursorView.swift
@@ -9,9 +9,9 @@ import AppKit
/// Animates a cursor.
class CursorView: NSView {
- let blinkDuration: TimeInterval?
- let color: CGColor
- let width: CGFloat
+ private let blinkDuration: TimeInterval?
+ private let color: NSColor
+ private let width: CGFloat
private var timer: Timer?
@@ -26,8 +26,8 @@ class CursorView: NSView {
/// - width: How wide the cursor should be.
init(
blinkDuration: TimeInterval? = 0.5,
- color: CGColor = NSColor.controlAccentColor.cgColor,
- width: CGFloat = 2.0
+ color: NSColor = NSColor.labelColor,
+ width: CGFloat = 1.0
) {
self.blinkDuration = blinkDuration
self.color = color
@@ -37,7 +37,7 @@ class CursorView: NSView {
frame.size.width = width
wantsLayer = true
- layer?.backgroundColor = color
+ layer?.backgroundColor = color.cgColor
if let blinkDuration {
timer = Timer.scheduledTimer(withTimeInterval: blinkDuration, repeats: true, block: { [weak self] _ in
diff --git a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
index 86b937ac5..8e0b5c0ab 100644
--- a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
@@ -97,6 +97,12 @@ class TextSelectionManager {
NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification))
}
+ internal func removeCursors() {
+ for textSelection in textSelections {
+ textSelection.view?.removeFromSuperview()
+ }
+ }
+
/// Draws line backgrounds and selection rects for each selection in the given rect.
/// - Parameter rect: The rect to draw in.
internal func drawSelections(in rect: NSRect) {
@@ -113,14 +119,14 @@ class TextSelectionManager {
context.fill(
CGRect(
x: rect.minX,
- y: linePosition.yPos,
+ y: layoutManager?.pointForOffset(linePosition.range.location)?.y ?? linePosition.yPos,
width: rect.width,
height: linePosition.height
)
)
} else {
// TODO: Highlight Selection Ranges
-
+
// guard let selectionPointMin = layoutManager.pointForOffset(selection.range.location),
// let selectionPointMax = layoutManager.pointForOffset(selection.range.max) else {
// continue
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
index c87f7f6ac..07d5ba8c6 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
@@ -29,7 +29,12 @@ class TextView: NSView, NSTextContent {
textStorage.setAttributedString(.init(string: string))
}
- public var font: NSFont
+ public var font: NSFont {
+ didSet {
+ setNeedsDisplay()
+ layoutManager.setNeedsLayout()
+ }
+ }
public var lineHeight: CGFloat
public var wrapLines: Bool
public var editorOverscroll: CGFloat
@@ -125,6 +130,7 @@ class TextView: NSView, NSTextContent {
open override func resignFirstResponder() -> Bool {
isFirstResponder = false
+ selectionManager.removeCursors()
return super.resignFirstResponder()
}
diff --git a/Sources/CodeEditTextView/TextView/TextViewController.swift b/Sources/CodeEditTextView/TextView/TextViewController.swift
index 31c929f13..d254c6409 100644
--- a/Sources/CodeEditTextView/TextView/TextViewController.swift
+++ b/Sources/CodeEditTextView/TextView/TextViewController.swift
@@ -17,7 +17,11 @@ public class TextViewController: NSViewController {
public var string: Binding
public var language: CodeLanguage
- public var font: NSFont
+ public var font: NSFont {
+ didSet {
+ textView.font = font
+ }
+ }
public var theme: EditorTheme
public var lineHeight: CGFloat
public var wrapLines: Bool
From 8452403b27754cf224c6d87e42622f013b738312 Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Wed, 23 Aug 2023 16:31:35 -0500
Subject: [PATCH 23/75] Fix text position calculation
---
Sources/CodeEditTextView/Gutter/GutterView.swift | 13 +++++++------
.../TextView/TextLayoutManager/LineFragment.swift | 8 ++++++++
.../TextLayoutManager/LineFragmentView.swift | 2 +-
.../TextLayoutManager/TextLayoutManager.swift | 1 -
.../TextView/TextLayoutManager/Typesetter.swift | 8 +++++++-
5 files changed, 23 insertions(+), 9 deletions(-)
diff --git a/Sources/CodeEditTextView/Gutter/GutterView.swift b/Sources/CodeEditTextView/Gutter/GutterView.swift
index d979654db..88c824810 100644
--- a/Sources/CodeEditTextView/Gutter/GutterView.swift
+++ b/Sources/CodeEditTextView/Gutter/GutterView.swift
@@ -92,8 +92,8 @@ class GutterView: NSView {
let maxCtLine = CTLineCreateWithAttributedString(
NSAttributedString(string: String(repeating: "0", count: lineStorageDigits), attributes: attributes)
)
- let bounds = CTLineGetBoundsWithOptions(maxCtLine, CTLineBoundsOptions())
- maxWidth = max(maxWidth, bounds.width)
+ let width = CTLineGetTypographicBounds(maxCtLine, nil, nil, nil)
+ maxWidth = max(maxWidth, width)
maxLineLength = lineStorageDigits
}
@@ -120,7 +120,7 @@ class GutterView: NSView {
context.fill(
CGRect(
x: 0.0,
- y: textView.layoutManager.pointForOffset(line.range.location)?.y ?? line.yPos,
+ y: line.yPos,
width: maxWidth + edgeInsets.horizontal,
height: line.height
)
@@ -143,11 +143,12 @@ class GutterView: NSView {
NSAttributedString(string: "\(linePosition.index + 1)", attributes: attributes)
)
let fragment: LineFragment? = linePosition.data.typesetter.lineFragments.first?.data
+ var ascent: CGFloat = 0
+ let lineNumberWidth = CTLineGetTypographicBounds(ctLine, &ascent, nil, nil)
- let topPadding: CGFloat = ((fragment?.scaledHeight ?? 0) - (fragment?.height ?? 0))/2.0
- let yPos = linePosition.yPos + (fragment?.height ?? 0) - topPadding
+ let yPos = linePosition.yPos + ascent + (fragment?.heightDifference ?? 0)/2
// Leading padding + (width - linewidth)
- let xPos = edgeInsets.leading + (maxWidth - CTLineGetBoundsWithOptions(ctLine, CTLineBoundsOptions()).width)
+ let xPos = edgeInsets.leading + (maxWidth - lineNumberWidth)
context.textPosition = CGPoint(
x: xPos,
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragment.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragment.swift
index d59db3e52..3bf87a73e 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragment.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragment.swift
@@ -12,17 +12,25 @@ final class LineFragment: Identifiable {
var ctLine: CTLine
let width: CGFloat
let height: CGFloat
+ let descent: CGFloat
let scaledHeight: CGFloat
+ @inlinable
+ var heightDifference: CGFloat {
+ scaledHeight - height
+ }
+
init(
ctLine: CTLine,
width: CGFloat,
height: CGFloat,
+ descent: CGFloat,
lineHeightMultiplier: CGFloat
) {
self.ctLine = ctLine
self.width = width
self.height = height
+ self.descent = descent
self.scaledHeight = height * lineHeightMultiplier
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentView.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentView.swift
index f3773559b..d551ec2ab 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentView.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentView.swift
@@ -32,7 +32,7 @@ final class LineFragmentView: NSView {
context.textMatrix = .init(scaleX: 1, y: -1)
context.textPosition = CGPoint(
x: 0,
- y: lineFragment.height - ((lineFragment.scaledHeight - lineFragment.height)/2)
+ y: lineFragment.height - lineFragment.descent + (lineFragment.heightDifference/2)
)
CTLineDraw(lineFragment.ctLine, context)
context.restoreGState()
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
index 7759f0cee..38e1993a2 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -173,7 +173,6 @@ final class TextLayoutManager: NSObject {
return CGPoint(
x: xPos + gutterWidth,
y: linePosition.yPos + fragmentPosition.yPos
- + (fragmentPosition.data.height - fragmentPosition.data.scaledHeight)/2
)
}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter.swift
index 9b07bf40a..731e7a18e 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter.swift
@@ -52,7 +52,13 @@ final class Typesetter {
var leading: CGFloat = 0
let width = CGFloat(CTLineGetTypographicBounds(ctLine, &ascent, &descent, &leading))
let height = ascent + descent + leading
- return LineFragment(ctLine: ctLine, width: width, height: height, lineHeightMultiplier: lineHeightMultiplier)
+ return LineFragment(
+ ctLine: ctLine,
+ width: width,
+ height: height,
+ descent: descent,
+ lineHeightMultiplier: lineHeightMultiplier
+ )
}
// MARK: - Line Breaks
From edbe8d3a695b94ffdbb377090dc4434d680053bf Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Wed, 23 Aug 2023 16:54:04 -0500
Subject: [PATCH 24/75] Spelling Error
---
TODO.md | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/TODO.md b/TODO.md
index ed6a3a3fe..ae2853c5b 100644
--- a/TODO.md
+++ b/TODO.md
@@ -24,7 +24,7 @@
- [] Command & control arrow keys
- [] Page up and down
- [] tab widths & indents
-- [] paramater updating
+- [] parameter updating
- [] tab & indent options
- [] kern
- [] theme
@@ -39,7 +39,6 @@
- [] language
- [] undo/redo
- [] sync system appearance
-- [x] update cursor position
- [] update text (from outside)
- [] highlight brackets
- [] textformation integration
From 5a7bbcd5d66bb4f21b76d0ceebcede4897db554e Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Thu, 24 Aug 2023 15:56:52 -0500
Subject: [PATCH 25/75] Begin RB delete, add docs
---
.../TextLayoutManager/TextLayoutManager.swift | 24 ++-
.../TextLineStorage+Node.swift | 36 +++-
.../TextLineStorage+Position.swift | 50 ++++++
.../TextLineStorage/TextLineStorage.swift | 121 +++++++------
.../TextSelectionManager.swift | 6 +-
.../TextView/TextView+NSTextInput.swift | 165 ++++++++++++++++--
6 files changed, 328 insertions(+), 74 deletions(-)
create mode 100644 Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Position.swift
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
index 38e1993a2..7ee9a6901 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -153,26 +153,38 @@ final class TextLayoutManager: NSObject {
}
/// Find a position for the character at a given offset.
- /// Returns the bottom-left corner of the character.
+ /// Returns the rect of the character at the given offset.
+ /// The rect may represent more than one unicode unit, for instance if the offset is at the beginning of an
+ /// emoji or non-latin glyph.
/// - Parameter offset: The offset to create the rect for.
/// - Returns: The found rect for the given offset.
- public func pointForOffset(_ offset: Int) -> CGPoint? {
+ public func rectForOffset(_ offset: Int) -> CGRect? {
guard let linePosition = lineStorage.getLine(atIndex: offset),
let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine(
atIndex: offset - linePosition.range.location
) else {
return nil
}
+ // Get the *real* length of the character at the offset. If this is a surrogate pair it'll return the correct
+ // length of the character at the offset.
+ let charLengthAtOffset = (textStorage.string as NSString).rangeOfComposedCharacterSequence(at: offset).length
- let xPos = CTLineGetOffsetForStringIndex(
+ let minXPos = CTLineGetOffsetForStringIndex(
fragmentPosition.data.ctLine,
offset - linePosition.range.location,
nil
)
+ let maxXPos = CTLineGetOffsetForStringIndex(
+ fragmentPosition.data.ctLine,
+ offset - linePosition.range.location + charLengthAtOffset,
+ nil
+ )
- return CGPoint(
- x: xPos + gutterWidth,
- y: linePosition.yPos + fragmentPosition.yPos
+ return CGRect(
+ x: minXPos + gutterWidth,
+ y: linePosition.yPos + fragmentPosition.yPos,
+ width: (maxXPos - minXPos) + gutterWidth,
+ height: fragmentPosition.data.scaledHeight
)
}
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift
index d78dc8d35..07e1b12a6 100644
--- a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift
+++ b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift
@@ -8,10 +8,12 @@
import Foundation
extension TextLineStorage {
+ @inlinable
func isRightChild(_ node: Node) -> Bool {
node.parent?.right == node
}
+ @inlinable
func isLeftChild(_ node: Node) -> Bool {
node.parent?.left == node
}
@@ -24,6 +26,36 @@ extension TextLineStorage {
}
}
+ /// Transplants a node with another node.
+ ///
+ /// ```
+ /// [a]
+ /// [u]_/ \_[b]
+ /// [c]_/ \_[v]
+ ///
+ /// call: transplant(u, v)
+ ///
+ /// [a]
+ /// [v]_/ \_[b]
+ /// [c]_/
+ ///
+ /// ```
+ ///
+ /// - Parameters:
+ /// - nodeU: The node to replace.
+ /// - nodeV: The node to insert in place of `nodeU`
+ @inlinable
+ func transplant(_ nodeU: Node, with nodeV: Node?) {
+ if nodeU.parent == nil {
+ root = nodeV
+ } else if isLeftChild(nodeU) {
+ nodeU.parent?.left = nodeV
+ } else {
+ nodeU.parent?.right = nodeV
+ }
+ nodeV?.parent = nodeU.parent
+ }
+
final class Node: Equatable {
enum Color {
case red
@@ -76,7 +108,7 @@ extension TextLineStorage {
lhs.data.id == rhs.data.id
}
- func minimum() -> Node? {
+ func minimum() -> Node {
if let left {
return left.minimum()
} else {
@@ -84,7 +116,7 @@ extension TextLineStorage {
}
}
- func maximum() -> Node? {
+ func maximum() -> Node {
if let right {
return right.maximum()
} else {
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Position.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Position.swift
new file mode 100644
index 000000000..13b7dd8f6
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Position.swift
@@ -0,0 +1,50 @@
+//
+// File.swift
+//
+//
+// Created by Khan Winter on 8/24/23.
+//
+
+import Foundation
+
+extension TextLineStorage where Data: Identifiable {
+ struct TextLinePosition {
+ init(data: Data, range: NSRange, yPos: CGFloat, height: CGFloat, index: Int) {
+ self.data = data
+ self.range = range
+ self.yPos = yPos
+ self.height = height
+ self.index = index
+ }
+
+ init(position: NodePosition) {
+ self.data = position.node.data
+ self.range = NSRange(location: position.textPos, length: position.node.length)
+ self.yPos = position.yPos
+ self.height = position.node.height
+ self.index = position.index
+ }
+
+ /// The data stored at the position
+ let data: Data
+ /// The range represented by the data
+ let range: NSRange
+ /// The y position of the data, on a top down y axis
+ let yPos: CGFloat
+ /// The height of the stored data
+ let height: CGFloat
+ /// The index of the position.
+ let index: Int
+ }
+
+ internal struct NodePosition {
+ /// The node storing information and the data stored at the position.
+ let node: Node
+ /// The y position of the data, on a top down y axis
+ let yPos: CGFloat
+ /// The location of the node in the document
+ let textPos: Int
+ /// The index of the node in the document.
+ let index: Int
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
index fcdb33989..286a0b9c6 100644
--- a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
+++ b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
@@ -7,53 +7,10 @@
import Foundation
-/// Implements a red-black tree for efficiently editing, storing and retrieving `TextLine`s.
+/// Implements a red-black tree for efficiently editing, storing and retrieving lines of text in a document.
final class TextLineStorage {
- struct TextLinePosition {
- init(data: Data, range: NSRange, yPos: CGFloat, height: CGFloat, index: Int) {
- self.data = data
- self.range = range
- self.yPos = yPos
- self.height = height
- self.index = index
- }
-
- init(position: NodePosition) {
- self.data = position.node.data
- self.range = NSRange(location: position.textPos, length: position.node.length)
- self.yPos = position.yPos
- self.height = position.node.height
- self.index = position.index
- }
-
- /// The data stored at the position
- let data: Data
- /// The range represented by the data
- let range: NSRange
- /// The y position of the data, on a top down y axis
- let yPos: CGFloat
- /// The height of the stored data
- let height: CGFloat
- /// The index of the position.
- let index: Int
- }
+ internal var root: Node?
- internal struct NodePosition {
- /// The node storing information and the data stored at the position.
- let node: Node
- /// The y position of the data, on a top down y axis
- let yPos: CGFloat
- /// The location of the node in the document
- let textPos: Int
- /// The index of the node in the document.
- let index: Int
- }
-
-#if DEBUG
- var root: Node?
-#else
- private var root: Node?
-#endif
/// The number of characters in the storage object.
private(set) public var length: Int = 0
/// The number of lines in the storage object
@@ -222,23 +179,85 @@ final class TextLineStorage {
metaFixup(startingAt: position.node, delta: delta, deltaHeight: deltaHeight, insertedNode: false)
}
- /// Deletes a line at the given index.
+ /// Deletes the line containing the given index.
///
- /// Will return if a line could not be found for the given index, and throw an assertion error if the index is
- /// out of bounds.
+ /// Will exit silently if a line could not be found for the given index, and throw an assertion error if the index
+ /// is out of bounds.
/// - Parameter index: The index to delete a line at.
public func delete(lineAt index: Int) {
assert(index >= 0 && index < self.length, "Invalid index, expected between 0 and \(self.length). Got \(index)")
-// guard let nodeZ = search(for: index).0 else { return }
-// var nodeX: Node
-// var nodeY: Node
+ if count == 1 {
+ removeAll()
+ return
+ }
+ guard let node = search(for: index)?.node else { return }
+ defer {
+ count -= 1
+ }
+ var originalColor = node.color
+ // Node to slice out
+ var nodeY: Node = node
+ // Node that replaces the sliced node.
+ var nodeX: Node?
+
+ if node.left == nil {
+ nodeX = node.right
+ transplant(node, with: node.right)
+ } else if node.right == nil {
+ nodeX = node.left
+ transplant(node, with: node.left)
+ } else {
+ nodeY = node.right!.minimum() // node.right is not null by case 2
+ originalColor = nodeY.color
+ nodeX = nodeY.right
+ if nodeY.parent == node {
+ nodeX?.parent = nodeY
+ } else {
+ transplant(nodeY, with: nodeY.right)
+ nodeY.right = node.right
+ nodeY.right?.parent = nodeY
+ }
+
+ transplant(node, with: nodeY)
+ nodeY.left = node.left
+ nodeY.left?.parent = nodeY
+ nodeY.color = node.color
+ }
+
+// if (z.left == TNULL) {
+// x = z.right;
+// rbTransplant(z, z.right);
+// } else if (z.right == TNULL) {
+// x = z.left;
+// rbTransplant(z, z.left);
+// } else {
+// y = minimum(z.right);
+// yOriginalColor = y.color;
+// x = y.right;
+// if (y.parent == z) {
+// x.parent = y;
+// } else {
+// rbTransplant(y, y.right);
+// y.right = z.right;
+// y.right.parent = y;
+// }
+//
+// rbTransplant(z, y);
+// y.left = z.left;
+// y.left.parent = y;
+// y.color = z.color;
+// }
+// if (yOriginalColor == 0) {
+// fixDelete(x);
+// }
}
public func removeAll() {
root = nil
count = 0
length = 0
+ height = 0
}
public func printTree() {
diff --git a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
index 8e0b5c0ab..69ba84ac8 100644
--- a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
@@ -48,7 +48,7 @@ class TextSelectionManager {
}
public var selectedLineBackgroundColor: NSColor = NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled)
-
+
private(set) var markedText: [MarkedText] = []
private(set) var textSelections: [TextSelection] = []
private weak var layoutManager: TextLayoutManager?
@@ -87,7 +87,7 @@ class TextSelectionManager {
.first
let cursorView = CursorView()
- cursorView.frame.origin = layoutManager?.pointForOffset(textSelection.range.location) ?? .zero
+ cursorView.frame.origin = (layoutManager?.rectForOffset(textSelection.range.location) ?? .zero).origin
cursorView.frame.size.height = lineFragment?.data.scaledHeight ?? 0
layoutView?.addSubview(cursorView)
@@ -119,7 +119,7 @@ class TextSelectionManager {
context.fill(
CGRect(
x: rect.minX,
- y: layoutManager?.pointForOffset(linePosition.range.location)?.y ?? linePosition.yPos,
+ y: layoutManager?.rectForOffset(linePosition.range.location)?.minY ?? linePosition.yPos,
width: rect.width,
height: linePosition.height
)
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift b/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
index 854adc5d5..caf3f2697 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
@@ -23,10 +23,23 @@ import TextStory
this should only happen if the `setMarkedText` method is called with `NSNotFound` for the replacement range's
location (indicating that the marked text should appear at the insertion location)
- **Note: Visual studio code Does not correctly support marked text, use Xcode as an example of this behavior.*
+ **Note: Visual studio code Does not correctly support marked text with multiple cursors,*
+ **use Xcode as an example of this behavior.*
*/
+/// All documentation in these methods is from the `NSTextInputClient` documentation, copied here for easy of use.
extension TextView: NSTextInputClient {
+ // MARK: - Insert Text
+
+ /// Inserts the given string into the receiver, replacing the specified content.
+ ///
+ /// Programmatic modification of the text is best done by operating on the text storage directly.
+ /// Because this method pertains to the actions of the user, the text view must be editable for the
+ /// insertion to work.
+ ///
+ /// - Parameters:
+ /// - string: The text to insert, either an NSString or NSAttributedString instance.
+ /// - replacementRange: The range of content to replace in the receiver’s text storage.
@objc public func insertText(_ string: Any, replacementRange: NSRange) {
guard isEditable else { return }
layoutManager.beginTransaction()
@@ -54,26 +67,88 @@ extension TextView: NSTextInputClient {
selectionManager?.updateSelectionViews()
}
+ // MARK: - Marked Text
+
+ /// Replaces a specified range in the receiver’s text storage with the given string and sets the selection.
+ ///
+ /// If there is no marked text, the current selection is replaced. If there is no selection, the string is
+ /// inserted at the insertion point.
+ ///
+ /// When `string` is an `NSString` object, the receiver is expected to render the marked text with
+ /// distinguishing appearance (for example, `NSTextView` renders with `markedTextAttributes`).
+ ///
+ /// - Parameters:
+ /// - string: The string to insert. Can be either an NSString or NSAttributedString instance.
+ /// - selectedRange: The range to set as the selection, computed from the beginning of the inserted string.
+ /// - replacementRange: The range to replace, computed from the beginning of the marked text.
@objc public func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
-
+ // TODO: setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange)
}
+ /// Unmarks the marked text.
+ ///
+ /// The receiver removes any marking from pending input text and disposes of the marked text as it wishes.
+ /// The text view should accept the marked text as if it had been inserted normally.
+ /// If there is no marked text, the invocation of this method has no effect.
@objc public func unmarkText() {
-
+ // TODO: unmarkText()
}
+ /// Returns the range of selected text.
+ /// The returned range measures from the start of the receiver’s text storage, that is, from 0 to the document
+ /// length.
+ /// - Returns: The range of selected text or {NSNotFound, 0} if there is no selection.
@objc public func selectedRange() -> NSRange {
- return selectionManager?.textSelections.first?.range ?? NSRange.zero
+ return selectionManager?.textSelections.first?.range ?? NSRange(location: NSNotFound, length: 0)
}
+ /// Returns the range of the marked text.
+ ///
+ /// The returned range measures from the start of the receiver’s text storage. The return value’s location is
+ /// `NSNotFound` and its length is `0` if and only if `hasMarkedText()` returns false.
+ ///
+ /// - Returns: The range of marked text or {NSNotFound, 0} if there is no marked range.
@objc public func markedRange() -> NSRange {
- .zero
+ // TODO: markedRange()
+ return NSRange(location: NSNotFound, length: 0)
}
+ /// Returns a Boolean value indicating whether the receiver has marked text.
+ ///
+ /// The text view itself may call this method to determine whether there currently is marked text.
+ /// NSTextView, for example, disables the Edit > Copy menu item when this method returns true.
+ ///
+ /// - Returns: true if the receiver has marked text; otherwise false.
@objc public func hasMarkedText() -> Bool {
- false
+ // TODO: hasMarkedText()
+ return false
+ }
+
+ /// Returns an array of attribute names recognized by the receiver.
+ ///
+ /// Returns an empty array if no attributes are supported. See NSAttributedString Application Kit Additions
+ /// Reference for the set of string constants representing standard attributes.
+ ///
+ /// - Returns: An array of NSString objects representing names for the supported attributes.
+ @objc public func validAttributesForMarkedText() -> [NSAttributedString.Key] {
+ [.underlineStyle, .underlineColor]
}
+ // MARK: - Contents
+
+ /// Returns an attributed string derived from the given range in the receiver's text storage.
+ ///
+ /// An implementation of this method should be prepared for aRange to be out of bounds.
+ /// For example, the InkWell text input service can ask for the contents of the text input client
+ /// that extends beyond the document’s range. In this case, you should return the
+ /// intersection of the document’s range and aRange. If the location of aRange is completely outside of the
+ /// document’s range, return nil.
+ ///
+ /// - Parameters:
+ /// - range: The range in the text storage from which to create the returned string.
+ /// - actualRange: The actual range of the returned string if it was adjusted, for example, to a grapheme cluster
+ /// boundary or for performance or other reasons. NULL if range was not adjusted.
+ /// - Returns: The string created from the given range. May return nil.
@objc public func attributedSubstring(
forProposedRange range: NSRange,
actualRange: NSRangePointer?
@@ -83,17 +158,83 @@ extension TextView: NSTextInputClient {
return textStorage.attributedSubstring(from: realRange)
}
- @objc public func validAttributesForMarkedText() -> [NSAttributedString.Key] {
- []
+ /// Returns an attributed string representing the receiver's text storage.
+ /// - Returns: The attributed string of the receiver’s text storage.
+ @objc public func attributedString() -> NSAttributedString {
+ textStorage.attributedSubstring(from: documentRange)
}
+ // MARK: - Positions
+
+ /// Returns the first logical boundary rectangle for characters in the given range.
+ /// - Parameters:
+ /// - range: The character range whose boundary rectangle is returned.
+ /// - actualRange: If non-NULL, contains the character range corresponding to the returned area if it was
+ /// adjusted, for example, to a grapheme cluster boundary or characters in the first line fragment.
+ /// - Returns: The boundary rectangle for the given range of characters, in *screen* coordinates.
+ /// The rectangle’s size value can be negative if the text flows to the left.
@objc public func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect {
- print(#function)
- return .zero
+ if actualRange != nil {
+ let realRange = (textStorage.string as NSString).rangeOfComposedCharacterSequences(for: range)
+ if realRange != range {
+ actualRange?.pointee = realRange
+ }
+ }
+
+ let localRect = (layoutManager.rectForOffset(range.location) ?? .zero)
+ let windowRect = convert(localRect, to: nil)
+ return window?.convertToScreen(windowRect) ?? .zero
}
+ /// Returns the index of the character whose bounding rectangle includes the given point.
+ /// - Parameter point: The point to test, in *screen* coordinates.
+ /// - Returns: The character index, measured from the start of the receiver’s text storage, of the character
+ /// containing the given point. Returns NSNotFound if the cursor is not within a character’s
+ /// bounding rectangle.
@objc public func characterIndex(for point: NSPoint) -> Int {
- print(#function)
- return layoutManager.textOffsetAtPoint(point) ?? NSNotFound
+ guard let windowPoint = window?.convertPoint(fromScreen: point) else {
+ return NSNotFound
+ }
+ let localPoint = convert(windowPoint, from: nil)
+ return layoutManager.textOffsetAtPoint(localPoint) ?? NSNotFound
+ }
+
+ /// Returns the fraction of the distance from the left side of the character to the right side that a given point
+ /// lies.
+ ///
+ /// For purposes such as dragging out a selection or placing the insertion point, a partial percentage less than or
+ /// equal to 0.5 indicates that aPoint should be considered as falling before the glyph; a partial percentage
+ /// greater than 0.5 indicates that it should be considered as falling after the glyph. If the nearest glyph doesn’t
+ /// lie under aPoint at all (for example, if aPoint is beyond the beginning or end of a line), this ratio is 0 or 1.
+ ///
+ /// For example, if the glyph stream contains the glyphs “A” and “b”, with the width of “A” being 13 points, and
+ /// aPoint is 8 points from the left side of “A”, then the fraction of the distance is 8/13, or 0.615. In this
+ /// case, the aPoint should be considered as falling between “A” and “b” for purposes such as dragging out a
+ /// selection or placing the insertion point.
+ ///
+ /// - Parameter point: The point to test.
+ /// - Returns: The fraction of the distance aPoint is through the glyph in which it lies. May be 0 or 1 if aPoint
+ /// is not within the bounding rectangle of a glyph (0 if the point is to the left or above the glyph;
+ /// 1 if it's to the right or below).
+ @objc public func fractionOfDistanceThroughGlyph(for point: NSPoint) -> CGFloat {
+ guard let offset = layoutManager.textOffsetAtPoint(point),
+ let characterRect = layoutManager.rectForOffset(offset) else { return 0 }
+ return (point.x - characterRect.minX)/characterRect.width
+ }
+
+ /// Returns the baseline position of a given character relative to the origin of rectangle returned by
+ /// `firstRect(forCharacterRange:actualRange:)`.
+ /// - Parameter anIndex: Index of the character whose baseline is tested.
+ /// - Returns: The vertical distance, in points, between the baseline of the character at anIndex and the rectangle
+ /// origin.
+ @objc public func baselineDeltaForCharacter(at anIndex: Int) -> CGFloat {
+ // Return the `descent` value from the line fragment at the index
+ guard let linePosition = layoutManager.textLineForOffset(anIndex),
+ let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine(
+ atIndex: anIndex - linePosition.range.location
+ ) else {
+ return 0
+ }
+ return fragmentPosition.data.descent
}
}
From a757f5cc18270903c1155a03f69cf3c6b5d44635 Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Fri, 25 Aug 2023 15:55:05 -0500
Subject: [PATCH 26/75] Begin Delete
---
.../TextLayoutManager/TextLayoutManager.swift | 1 +
.../TextSelectionManager.swift | 257 +++++++++++++++++-
.../TextView/TextView/TextView+Delete.swift | 46 ++++
.../TextView/TextView+NSTextInput.swift | 39 +--
.../TextView/TextView/TextView.swift | 28 +-
TODO.md | 5 +
6 files changed, 346 insertions(+), 30 deletions(-)
create mode 100644 Sources/CodeEditTextView/TextView/TextView/TextView+Delete.swift
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
index 7ee9a6901..04fe6e150 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -364,6 +364,7 @@ extension TextLayoutManager: NSTextStorageDelegate {
) {
if editedMask.contains(.editedCharacters) {
lineStorage.update(atIndex: editedRange.location, delta: delta, deltaHeight: 0)
+ // TODO: If delta < 0, handle delete.
}
invalidateLayoutForRange(editedRange)
}
diff --git a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
index 69ba84ac8..1f0e74aa4 100644
--- a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
@@ -6,6 +6,7 @@
//
import AppKit
+import TextStory
protocol TextSelectionManagerDelegate: AnyObject {
var font: NSFont { get }
@@ -37,12 +38,30 @@ class TextSelectionManager {
range.length == 0
}
- func didInsertText(length: Int) {
- range.length = 0
+ func didInsertText(length: Int, retainLength: Bool = false) {
+ if !retainLength {
+ range.length = 0
+ }
range.location += length
}
}
+ enum Destination {
+ case character
+ case word
+ case line
+ /// Eg: Bottom of screen
+ case container
+ case document
+ }
+
+ enum Direction {
+ case up
+ case down
+ case forward
+ case backward
+ }
+
class var selectionChangedNotification: Notification.Name {
Notification.Name("TextSelectionManager.TextSelectionChangedNotification")
}
@@ -52,11 +71,18 @@ class TextSelectionManager {
private(set) var markedText: [MarkedText] = []
private(set) var textSelections: [TextSelection] = []
private weak var layoutManager: TextLayoutManager?
- weak private var layoutView: NSView?
+ private weak var textStorage: NSTextStorage?
+ private weak var layoutView: NSView?
private weak var delegate: TextSelectionManagerDelegate?
- init(layoutManager: TextLayoutManager, layoutView: NSView?, delegate: TextSelectionManagerDelegate?) {
+ init(
+ layoutManager: TextLayoutManager,
+ textStorage: NSTextStorage,
+ layoutView: NSView?,
+ delegate: TextSelectionManagerDelegate?
+ ) {
self.layoutManager = layoutManager
+ self.textStorage = textStorage
self.layoutView = layoutView
self.delegate = delegate
textSelections = []
@@ -97,6 +123,15 @@ class TextSelectionManager {
NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification))
}
+ /// Notifies the selection manager of an edit and updates all selections accordingly.
+ /// - Parameters:
+ /// - delta: The change in length of the document
+ /// - retainLength: Set to `true` if selections should keep their lengths after the edit.
+ /// By default all selection lengths are set to 0 after any edit.
+ func updateSelections(delta: Int, retainLength: Bool = false) {
+ textSelections.forEach { $0.didInsertText(length: delta, retainLength: retainLength) }
+ }
+
internal func removeCursors() {
for textSelection in textSelections {
textSelection.view?.removeFromSuperview()
@@ -115,15 +150,16 @@ class TextSelectionManager {
guard let linePosition = layoutManager?.textLineForOffset(textSelection.range.location) else {
continue
}
- context.setFillColor(selectedLineBackgroundColor.cgColor)
- context.fill(
- CGRect(
- x: rect.minX,
- y: layoutManager?.rectForOffset(linePosition.range.location)?.minY ?? linePosition.yPos,
- width: rect.width,
- height: linePosition.height
- )
+ let selectionRect = CGRect(
+ x: rect.minX,
+ y: layoutManager?.rectForOffset(linePosition.range.location)?.minY ?? linePosition.yPos,
+ width: rect.width,
+ height: linePosition.height
)
+ if selectionRect.intersects(rect) {
+ context.setFillColor(selectedLineBackgroundColor.cgColor)
+ context.fill(selectionRect)
+ }
} else {
// TODO: Highlight Selection Ranges
@@ -147,4 +183,201 @@ class TextSelectionManager {
}
context.restoreGState()
}
+
+ // MARK: - Selection Manipulation
+
+ public func rangeOfSelection(from offset: Int, direction: Direction, destination: Destination) -> NSRange {
+ switch direction {
+ case .backward:
+ return extendSelection(from: offset, destination: destination, delta: -1)
+ case .forward:
+ return NSRange(location: offset, length: 0)
+ case .up: // TODO: up
+ return NSRange(location: offset, length: 0)
+ case .down: // TODO: down
+ return NSRange(location: offset, length: 0)
+ }
+ }
+
+ /// Extends a selection from the given offset determining the length by the destination.
+ ///
+ /// Returns a new range that needs to be merged with an existing selection range using `NSRange.formUnion`
+ ///
+ /// - Parameters:
+ /// - offset: The location to start extending the selection from.
+ /// - destination: Determines how far the selection is extended.
+ /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards.
+ /// - Returns: A new range to merge with a selection.
+ private func extendSelection(from offset: Int, destination: Destination, delta: Int) -> NSRange {
+ guard let string = textStorage?.string as NSString? else { return NSRange(location: offset, length: 0) }
+
+ switch destination {
+ case .character:
+ return extendSelectionCharacter(string: string, from: offset, delta: delta)
+ case .word:
+ return extendSelectionWord(string: string, from: offset, delta: delta)
+ case .line:
+ return extendSelectionLine(string: string, from: offset, delta: delta)
+ case .container:
+ return extendSelectionContainer(from: offset, delta: delta)
+ case .document:
+ if delta > 0 {
+ return NSRange(location: offset, length: string.length - offset)
+ } else {
+ return NSRange(location: 0, length: offset)
+ }
+ }
+ }
+
+ /// Extends the selection by a single character.
+ ///
+ /// The range returned from this method can be longer than `1` character if the character in the extended direction
+ /// is a member of a grapheme cluster.
+ ///
+ /// - Parameters:
+ /// - string: The reference string to use.
+ /// - offset: The location to start extending the selection from.
+ /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards.
+ /// - Returns: The range of the extended selection.
+ private func extendSelectionCharacter(string: NSString, from offset: Int, delta: Int) -> NSRange {
+ if delta > 0 {
+ return string.rangeOfComposedCharacterSequences(for: NSRange(location: offset, length: 1))
+ } else {
+ return string.rangeOfComposedCharacterSequences(for: NSRange(location: offset - 1, length: 1))
+ }
+ }
+
+ /// Extends the selection by one "word".
+ ///
+ /// Words in this case begin after encountering an alphanumeric character, and extend until either a whitespace
+ /// or punctuation character.
+ ///
+ /// - Parameters:
+ /// - string: The reference string to use.
+ /// - offset: The location to start extending the selection from.
+ /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards.
+ /// - Returns: The range of the extended selection.
+ private func extendSelectionWord(string: NSString, from offset: Int, delta: Int) -> NSRange {
+ var enumerationOptions: NSString.EnumerationOptions = .byCaretPositions
+ if delta < 0 {
+ enumerationOptions.formUnion(.reverse)
+ }
+ guard let line = layoutManager?.textLineForOffset(offset),
+ let lineFragment = line.data.typesetter.lineFragments.getLine(atIndex: offset - line.range.location)
+ else {
+ return NSRange(location: offset, length: 0)
+ }
+ let lineStart = line.range.location + lineFragment.range.location
+ let lineEnd = line.range.location + lineFragment.range.max
+ var rangeToDelete = NSRange(location: offset, length: 0)
+
+ var hasFoundValidWordChar = false
+ string.enumerateSubstrings(
+ in: NSRange(
+ location: delta > 0 ? offset : lineStart,
+ length: delta > 0 ? lineEnd - offset : offset - lineStart
+ ),
+ options: enumerationOptions
+ ) { substring, _, _, stop in
+ guard let substring = substring else {
+ stop.pointee = true
+ return
+ }
+
+ if hasFoundValidWordChar && CharacterSet
+ .whitespacesWithoutNewlines
+ .union(.punctuationCharacters)
+ .isSuperset(of: CharacterSet(charactersIn: substring)) {
+ stop.pointee = true
+ return
+ } else if CharacterSet.alphanumerics.isSuperset(of: CharacterSet(charactersIn: substring)) {
+ hasFoundValidWordChar = true
+ }
+ rangeToDelete.length += substring.count
+
+ if delta < 0 {
+ rangeToDelete.location -= substring.count
+ }
+ }
+
+ return rangeToDelete
+ }
+
+ /// Extends the selection by one line in the direction specified.
+ ///
+ /// If extending backwards, this method will return the beginning of the leading non-whitespace characters
+ /// in the line. If the offset is located in the leading whitespace it will return the real line beginning.
+ /// For Example
+ /// ```
+ /// ^ = offset, ^--^ = returned range
+ /// Line:
+ /// Loren Ipsum
+ /// ^
+ /// Extend 1st Call:
+ /// Loren Ipsum
+ /// ^-----^
+ /// Extend 2nd Call:
+ /// Loren Ipsum
+ /// ^----^
+ /// ```
+ ///
+ /// - Parameters:
+ /// - string: The reference string to use.
+ /// - offset: The location to start extending the selection from.
+ /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards.
+ /// - Returns: The range of the extended selection.
+ private func extendSelectionLine(string: NSString, from offset: Int, delta: Int) -> NSRange {
+ guard let line = layoutManager?.textLineForOffset(offset),
+ let lineFragment = line.data.typesetter.lineFragments.getLine(atIndex: offset - line.range.location)
+ else {
+ return NSRange(location: offset, length: 0)
+ }
+ print(line.range, lineFragment.range)
+ let lineBound = delta > 0
+ ? line.range.location + lineFragment.range.max
+ : line.range.location + lineFragment.range.location
+
+ var foundRange = NSRange(
+ location: min(lineBound, offset),
+ length: max(lineBound, offset) - min(lineBound, offset)
+ )
+ let originalFoundRange = foundRange
+
+ // Only do this if we're going backwards.
+ if delta < 0 {
+ string.enumerateSubstrings(in: foundRange, options: .byCaretPositions) { substring, _, _, stop in
+ if let substring = substring as String? {
+ if CharacterSet.whitespacesWithoutNewlines.isSuperset(of: CharacterSet(charactersIn: substring)) {
+ foundRange.location += 1
+ foundRange.length -= 1
+ } else {
+ stop.pointee = true
+ }
+ } else {
+ stop.pointee = true
+ }
+ }
+ }
+
+ return foundRange.length == 0 ? originalFoundRange : foundRange
+ }
+
+ /// Extends a selection one "container" long.
+ /// - Parameters:
+ /// - offset: The location to start extending the selection from.
+ /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards.
+ /// - Returns: The range of the extended selection.
+ private func extendSelectionContainer(from offset: Int, delta: Int) -> NSRange {
+ guard let layoutView, let endOffset = layoutManager?.textOffsetAtPoint(
+ CGPoint(
+ x: delta > 0 ? layoutView.frame.maxX : layoutView.frame.minX,
+ y: delta > 0 ? layoutView.frame.maxY : layoutView.frame.minY
+ )
+ ) else {
+ return NSRange(location: offset, length: 0)
+ }
+ return endOffset > offset
+ ? NSRange(location: offset, length: endOffset - offset)
+ : NSRange(location: endOffset, length: offset - endOffset)
+ }
}
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView+Delete.swift b/Sources/CodeEditTextView/TextView/TextView/TextView+Delete.swift
new file mode 100644
index 000000000..ade97805f
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView+Delete.swift
@@ -0,0 +1,46 @@
+//
+// TextView+Delete.swift
+//
+//
+// Created by Khan Winter on 8/24/23.
+//
+
+import AppKit
+
+extension TextView {
+ open override func deleteBackward(_ sender: Any?) {
+ delete(direction: .backward, destination: .character)
+ }
+
+ open override func deleteWordBackward(_ sender: Any?) {
+ delete(direction: .backward, destination: .word)
+ }
+
+ open override func deleteToBeginningOfLine(_ sender: Any?) {
+ delete(direction: .backward, destination: .line)
+ }
+
+ private func delete(direction: TextSelectionManager.Direction, destination: TextSelectionManager.Destination) {
+ print(#function, direction, destination)
+ /// Extend each selection by a distance specified by `destination`, then update both storage and the selection.
+ for textSelection in selectionManager.textSelections {
+ let extendedRange = selectionManager.rangeOfSelection(
+ from: textSelection.range.location,
+ direction: direction,
+ destination: destination
+ )
+ textSelection.range.formUnion(extendedRange)
+ }
+
+ replaceCharacters(in: selectionManager.textSelections.map(\.range), with: "")
+
+ var delta: Int = 0
+ for textSelection in selectionManager.textSelections {
+ textSelection.range.location -= delta
+ delta += textSelection.range.length
+ textSelection.range.length = 0
+ }
+
+ selectionManager.updateSelectionViews()
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift b/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
index caf3f2697..dfe21774f 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
@@ -44,24 +44,31 @@ extension TextView: NSTextInputClient {
guard isEditable else { return }
layoutManager.beginTransaction()
textStorage.beginEditing()
- selectionManager?.textSelections.forEach { selection in
- switch string {
- case let string as NSString:
- textStorage.replaceCharacters(in: selection.range, with: string as String)
- selection.didInsertText(length: string.length)
- _undoManager?.registerMutation(
- TextMutation(string: string as String, range: selection.range, limit: textStorage.length)
- )
- case let string as NSAttributedString:
- textStorage.replaceCharacters(in: selection.range, with: string)
- selection.didInsertText(length: string.length)
- _undoManager?.registerMutation(
- TextMutation(string: string.string, range: selection.range, limit: textStorage.length)
- )
- default:
- assertionFailure("\(#function) called with invalid string type. Expected String or NSAttributedString.")
+
+ let insertString: String
+ switch string {
+ case let string as NSString:
+ insertString = string as String
+ case let string as NSAttributedString:
+ insertString = string.string
+ default:
+ insertString = ""
+ assertionFailure("\(#function) called with invalid string type. Expected String or NSAttributedString.")
+ }
+
+ if replacementRange.location == NSNotFound {
+ replaceCharacters(in: selectionManager.textSelections.map { $0.range }, with: insertString)
+ // TODO: This actually won't fix the selection ranges. See the delete method
+ selectionManager.textSelections.forEach { selection in
+ selection.didInsertText(length: insertString.count == 0 ? -selection.range.length : insertString.count)
}
+ } else {
+ replaceCharacters(in: replacementRange, with: insertString)
+ selectionManager.updateSelections(
+ delta: insertString.count == 0 ? -replacementRange.length : replacementRange.length
+ )
}
+
textStorage.endEditing()
layoutManager.endTransaction()
selectionManager?.updateSelectionViews()
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
index 07d5ba8c6..944beab4e 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
@@ -7,6 +7,7 @@
import AppKit
import STTextView
+import TextStory
/**
@@ -108,6 +109,7 @@ class TextView: NSView, NSTextContent {
selectionManager = TextSelectionManager(
layoutManager: layoutManager,
+ textStorage: textStorage,
layoutView: self, // TODO: This is an odd syntax... consider reworking this
delegate: self
)
@@ -182,7 +184,7 @@ class TextView: NSView, NSTextContent {
if !(inputContext?.handleEvent(event) ?? false) {
interpretKeyEvents([event])
} else {
-
+ // Handle key events?
}
}
@@ -201,7 +203,29 @@ class TextView: NSView, NSTextContent {
}
}
- // MARK: - Draw
+ public func replaceCharacters(in ranges: [NSRange], with string: String) {
+ guard isEditable else { return }
+ layoutManager.beginTransaction()
+ textStorage.beginEditing()
+ for range in ranges where range.length != 0 {
+ replaceCharactersNoCheck(in: range, with: string)
+ _undoManager?.registerMutation(
+ TextMutation(string: string as String, range: range, limit: textStorage.length)
+ )
+ }
+ textStorage.endEditing()
+ layoutManager.endTransaction()
+ }
+
+ public func replaceCharacters(in range: NSRange, with string: String) {
+ replaceCharacters(in: [range], with: string)
+ }
+
+ private func replaceCharactersNoCheck(in range: NSRange, with string: String) {
+ textStorage.replaceCharacters(in: range, with: string)
+ }
+
+ // MARK: - Layout
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
diff --git a/TODO.md b/TODO.md
index ae2853c5b..bde16b278 100644
--- a/TODO.md
+++ b/TODO.md
@@ -14,6 +14,10 @@
- [x] isEditable
- [x] Insert
- [] Delete
+ - [x] Delete line
+ - [x] Delete word
+ - [x] Delete character
+ - [x] Delete across line boundaries
- [x] Paste
- [x] Line Numbers
- [] select text
@@ -42,3 +46,4 @@
- [] update text (from outside)
- [] highlight brackets
- [] textformation integration
+- [] make non scrollable (just remove scroll view, add gutter above textview & set width )
From 30a6222bb47fa516fe4efde5dacbe351888214ea Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Sat, 26 Aug 2023 07:35:53 -0500
Subject: [PATCH 27/75] Add TextSelectionManager as NSTextStorage delegate
---
...lectionManager+SelectionManipulation.swift | 238 +++++++++++++++++
.../TextSelectionManager.swift | 243 ++++--------------
.../TextView/TextView/TextView+Delete.swift | 42 ++-
.../TextView/TextView+NSTextInput.swift | 10 +-
.../TextView/TextView/TextView.swift | 6 +-
TODO.md | 2 +-
6 files changed, 320 insertions(+), 221 deletions(-)
create mode 100644 Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift
diff --git a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift
new file mode 100644
index 000000000..417044129
--- /dev/null
+++ b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift
@@ -0,0 +1,238 @@
+//
+// TextSelectionManager+SelectionManipulation.swift
+//
+//
+// Created by Khan Winter on 8/26/23.
+//
+
+import AppKit
+
+extension TextSelectionManager {
+ // MARK: - Range Of Selection
+
+ /// Creates a range for a new selection given a starting point, direction, and destination.
+ /// - Parameters:
+ /// - offset: The location to start the selection from.
+ /// - direction: The direction the selection should be created in.
+ /// - destination: Determines how far the selection is.
+ /// - decomposeCharacters: Set to `true` to treat grapheme clusters as individual characters.
+ /// - Returns: A range of a new selection based on the direction and destination.
+ public func rangeOfSelection(
+ from offset: Int,
+ direction: Direction,
+ destination: Destination,
+ decomposeCharacters: Bool = false
+ ) -> NSRange {
+ switch direction {
+ case .backward:
+ return extendSelection(from: offset, destination: destination, delta: -1)
+ case .forward:
+ return extendSelection(from: offset, destination: destination, delta: 1)
+ case .up: // TODO: up
+ return NSRange(location: offset, length: 0)
+ case .down: // TODO: down
+ return NSRange(location: offset, length: 0)
+ }
+ }
+
+ /// Extends a selection from the given offset determining the length by the destination.
+ ///
+ /// Returns a new range that needs to be merged with an existing selection range using `NSRange.formUnion`
+ ///
+ /// - Parameters:
+ /// - offset: The location to start extending the selection from.
+ /// - destination: Determines how far the selection is extended.
+ /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards.
+ /// - decomposeCharacters: Set to `true` to treat grapheme clusters as individual characters.
+ /// - Returns: A new range to merge with a selection.
+ private func extendSelection(
+ from offset: Int,
+ destination: Destination,
+ delta: Int,
+ decomposeCharacters: Bool = false
+ ) -> NSRange {
+ guard let string = textStorage?.string as NSString? else { return NSRange(location: offset, length: 0) }
+
+ switch destination {
+ case .character:
+ return extendSelectionCharacter(
+ string: string,
+ from: offset,
+ delta: delta,
+ decomposeCharacters: decomposeCharacters
+ )
+ case .word:
+ return extendSelectionWord(string: string, from: offset, delta: delta)
+ case .line:
+ return extendSelectionLine(string: string, from: offset, delta: delta)
+ case .container:
+ return extendSelectionContainer(from: offset, delta: delta)
+ case .document:
+ if delta > 0 {
+ return NSRange(location: offset, length: string.length - offset)
+ } else {
+ return NSRange(location: 0, length: offset)
+ }
+ }
+ }
+
+ // MARK: - Horizontal Methods
+
+ /// Extends the selection by a single character.
+ ///
+ /// The range returned from this method can be longer than `1` character if the character in the extended direction
+ /// is a member of a grapheme cluster.
+ ///
+ /// - Parameters:
+ /// - string: The reference string to use.
+ /// - offset: The location to start extending the selection from.
+ /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards.
+ /// - decomposeCharacters: Set to `true` to treat grapheme clusters as individual characters.
+ /// - Returns: The range of the extended selection.
+ private func extendSelectionCharacter(
+ string: NSString,
+ from offset: Int,
+ delta: Int,
+ decomposeCharacters: Bool
+ ) -> NSRange {
+ let range = delta > 0 ? NSRange(location: offset, length: 1) : NSRange(location: offset - 1, length: 1)
+ if decomposeCharacters {
+ return range
+ } else {
+ return string.rangeOfComposedCharacterSequences(for: range)
+ }
+ }
+
+ /// Extends the selection by one "word".
+ ///
+ /// Words in this case begin after encountering an alphanumeric character, and extend until either a whitespace
+ /// or punctuation character.
+ ///
+ /// - Parameters:
+ /// - string: The reference string to use.
+ /// - offset: The location to start extending the selection from.
+ /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards.
+ /// - Returns: The range of the extended selection.
+ private func extendSelectionWord(string: NSString, from offset: Int, delta: Int) -> NSRange {
+ var enumerationOptions: NSString.EnumerationOptions = .byCaretPositions
+ if delta < 0 {
+ enumerationOptions.formUnion(.reverse)
+ }
+ guard let line = layoutManager?.textLineForOffset(offset),
+ let lineFragment = line.data.typesetter.lineFragments.getLine(atIndex: offset - line.range.location)
+ else {
+ return NSRange(location: offset, length: 0)
+ }
+ let lineStart = line.range.location + lineFragment.range.location
+ let lineEnd = line.range.location + lineFragment.range.max
+ var rangeToDelete = NSRange(location: offset, length: 0)
+
+ var hasFoundValidWordChar = false
+ string.enumerateSubstrings(
+ in: NSRange(
+ location: delta > 0 ? offset : lineStart,
+ length: delta > 0 ? lineEnd - offset : offset - lineStart
+ ),
+ options: enumerationOptions
+ ) { substring, _, _, stop in
+ guard let substring = substring else {
+ stop.pointee = true
+ return
+ }
+
+ if hasFoundValidWordChar && CharacterSet
+ .whitespacesWithoutNewlines
+ .union(.punctuationCharacters)
+ .isSuperset(of: CharacterSet(charactersIn: substring)) {
+ stop.pointee = true
+ return
+ } else if CharacterSet.alphanumerics.isSuperset(of: CharacterSet(charactersIn: substring)) {
+ hasFoundValidWordChar = true
+ }
+ rangeToDelete.length += substring.count
+
+ if delta < 0 {
+ rangeToDelete.location -= substring.count
+ }
+ }
+
+ return rangeToDelete
+ }
+
+ /// Extends the selection by one line in the direction specified.
+ ///
+ /// If extending backwards, this method will return the beginning of the leading non-whitespace characters
+ /// in the line. If the offset is located in the leading whitespace it will return the real line beginning.
+ /// For Example
+ /// ```
+ /// ^ = offset, ^--^ = returned range
+ /// Line:
+ /// Loren Ipsum
+ /// ^
+ /// Extend 1st Call:
+ /// Loren Ipsum
+ /// ^-----^
+ /// Extend 2nd Call:
+ /// Loren Ipsum
+ /// ^----^
+ /// ```
+ ///
+ /// - Parameters:
+ /// - string: The reference string to use.
+ /// - offset: The location to start extending the selection from.
+ /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards.
+ /// - Returns: The range of the extended selection.
+ private func extendSelectionLine(string: NSString, from offset: Int, delta: Int) -> NSRange {
+ guard let line = layoutManager?.textLineForOffset(offset),
+ let lineFragment = line.data.typesetter.lineFragments.getLine(atIndex: offset - line.range.location)
+ else {
+ return NSRange(location: offset, length: 0)
+ }
+ let lineBound = delta > 0
+ ? line.range.location + lineFragment.range.max
+ : line.range.location + lineFragment.range.location
+
+ var foundRange = NSRange(
+ location: min(lineBound, offset),
+ length: max(lineBound, offset) - min(lineBound, offset)
+ )
+ let originalFoundRange = foundRange
+
+ // Only do this if we're going backwards.
+ if delta < 0 {
+ string.enumerateSubstrings(in: foundRange, options: .byCaretPositions) { substring, _, _, stop in
+ if let substring = substring as String? {
+ if CharacterSet.whitespacesWithoutNewlines.isSuperset(of: CharacterSet(charactersIn: substring)) {
+ foundRange.location += 1
+ foundRange.length -= 1
+ } else {
+ stop.pointee = true
+ }
+ } else {
+ stop.pointee = true
+ }
+ }
+ }
+
+ return foundRange.length == 0 ? originalFoundRange : foundRange
+ }
+
+ /// Extends a selection one "container" long.
+ /// - Parameters:
+ /// - offset: The location to start extending the selection from.
+ /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards.
+ /// - Returns: The range of the extended selection.
+ private func extendSelectionContainer(from offset: Int, delta: Int) -> NSRange {
+ guard let layoutView, let endOffset = layoutManager?.textOffsetAtPoint(
+ CGPoint(
+ x: delta > 0 ? layoutView.frame.maxX : layoutView.frame.minX,
+ y: delta > 0 ? layoutView.frame.maxY : layoutView.frame.minY
+ )
+ ) else {
+ return NSRange(location: offset, length: 0)
+ }
+ return endOffset > offset
+ ? NSRange(location: offset, length: endOffset - offset)
+ : NSRange(location: endOffset, length: offset - endOffset)
+ }
+}
diff --git a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
index 1f0e74aa4..530d80b77 100644
--- a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
@@ -19,12 +19,14 @@ protocol TextSelectionManagerDelegate: AnyObject {
///
/// Draws selections using a draw method similar to the `TextLayoutManager` class, and adds cursor views when
/// appropriate.
-class TextSelectionManager {
+class TextSelectionManager: NSObject {
struct MarkedText {
let range: NSRange
let attributedString: NSAttributedString
}
+ // MARK: - TextSelection
+
class TextSelection {
var range: NSRange
weak var view: CursorView?
@@ -37,13 +39,6 @@ class TextSelectionManager {
var isCursor: Bool {
range.length == 0
}
-
- func didInsertText(length: Int, retainLength: Bool = false) {
- if !retainLength {
- range.length = 0
- }
- range.location += length
- }
}
enum Destination {
@@ -62,6 +57,8 @@ class TextSelectionManager {
case backward
}
+ // MARK: - Properties
+
class var selectionChangedNotification: Notification.Name {
Notification.Name("TextSelectionManager.TextSelectionChangedNotification")
}
@@ -70,10 +67,10 @@ class TextSelectionManager {
private(set) var markedText: [MarkedText] = []
private(set) var textSelections: [TextSelection] = []
- private weak var layoutManager: TextLayoutManager?
- private weak var textStorage: NSTextStorage?
- private weak var layoutView: NSView?
- private weak var delegate: TextSelectionManagerDelegate?
+ internal weak var layoutManager: TextLayoutManager?
+ internal weak var textStorage: NSTextStorage?
+ internal weak var layoutView: NSView?
+ internal weak var delegate: TextSelectionManagerDelegate?
init(
layoutManager: TextLayoutManager,
@@ -85,10 +82,13 @@ class TextSelectionManager {
self.textStorage = textStorage
self.layoutView = layoutView
self.delegate = delegate
+ super.init()
textSelections = []
updateSelectionViews()
}
+ // MARK: - Selected Ranges
+
public func setSelectedRange(_ range: NSRange) {
textSelections.forEach { $0.view?.removeFromSuperview() }
textSelections = [TextSelection(range: range)]
@@ -101,6 +101,8 @@ class TextSelectionManager {
updateSelectionViews()
}
+ // MARK: - Selection Views
+
internal func updateSelectionViews() {
textSelections.forEach { $0.view?.removeFromSuperview() }
for textSelection in textSelections where textSelection.range.isEmpty {
@@ -138,6 +140,8 @@ class TextSelectionManager {
}
}
+ // MARK: - Draw
+
/// Draws line backgrounds and selection rects for each selection in the given rect.
/// - Parameter rect: The rect to draw in.
internal func drawSelections(in rect: NSRect) {
@@ -183,201 +187,44 @@ class TextSelectionManager {
}
context.restoreGState()
}
+}
- // MARK: - Selection Manipulation
-
- public func rangeOfSelection(from offset: Int, direction: Direction, destination: Destination) -> NSRange {
- switch direction {
- case .backward:
- return extendSelection(from: offset, destination: destination, delta: -1)
- case .forward:
- return NSRange(location: offset, length: 0)
- case .up: // TODO: up
- return NSRange(location: offset, length: 0)
- case .down: // TODO: down
- return NSRange(location: offset, length: 0)
- }
- }
-
- /// Extends a selection from the given offset determining the length by the destination.
- ///
- /// Returns a new range that needs to be merged with an existing selection range using `NSRange.formUnion`
- ///
- /// - Parameters:
- /// - offset: The location to start extending the selection from.
- /// - destination: Determines how far the selection is extended.
- /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards.
- /// - Returns: A new range to merge with a selection.
- private func extendSelection(from offset: Int, destination: Destination, delta: Int) -> NSRange {
- guard let string = textStorage?.string as NSString? else { return NSRange(location: offset, length: 0) }
-
- switch destination {
- case .character:
- return extendSelectionCharacter(string: string, from: offset, delta: delta)
- case .word:
- return extendSelectionWord(string: string, from: offset, delta: delta)
- case .line:
- return extendSelectionLine(string: string, from: offset, delta: delta)
- case .container:
- return extendSelectionContainer(from: offset, delta: delta)
- case .document:
- if delta > 0 {
- return NSRange(location: offset, length: string.length - offset)
- } else {
- return NSRange(location: 0, length: offset)
- }
- }
- }
-
- /// Extends the selection by a single character.
- ///
- /// The range returned from this method can be longer than `1` character if the character in the extended direction
- /// is a member of a grapheme cluster.
- ///
- /// - Parameters:
- /// - string: The reference string to use.
- /// - offset: The location to start extending the selection from.
- /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards.
- /// - Returns: The range of the extended selection.
- private func extendSelectionCharacter(string: NSString, from offset: Int, delta: Int) -> NSRange {
- if delta > 0 {
- return string.rangeOfComposedCharacterSequences(for: NSRange(location: offset, length: 1))
- } else {
- return string.rangeOfComposedCharacterSequences(for: NSRange(location: offset - 1, length: 1))
- }
- }
-
- /// Extends the selection by one "word".
- ///
- /// Words in this case begin after encountering an alphanumeric character, and extend until either a whitespace
- /// or punctuation character.
- ///
- /// - Parameters:
- /// - string: The reference string to use.
- /// - offset: The location to start extending the selection from.
- /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards.
- /// - Returns: The range of the extended selection.
- private func extendSelectionWord(string: NSString, from offset: Int, delta: Int) -> NSRange {
- var enumerationOptions: NSString.EnumerationOptions = .byCaretPositions
- if delta < 0 {
- enumerationOptions.formUnion(.reverse)
- }
- guard let line = layoutManager?.textLineForOffset(offset),
- let lineFragment = line.data.typesetter.lineFragments.getLine(atIndex: offset - line.range.location)
- else {
- return NSRange(location: offset, length: 0)
- }
- let lineStart = line.range.location + lineFragment.range.location
- let lineEnd = line.range.location + lineFragment.range.max
- var rangeToDelete = NSRange(location: offset, length: 0)
-
- var hasFoundValidWordChar = false
- string.enumerateSubstrings(
- in: NSRange(
- location: delta > 0 ? offset : lineStart,
- length: delta > 0 ? lineEnd - offset : offset - lineStart
- ),
- options: enumerationOptions
- ) { substring, _, _, stop in
- guard let substring = substring else {
- stop.pointee = true
- return
- }
-
- if hasFoundValidWordChar && CharacterSet
- .whitespacesWithoutNewlines
- .union(.punctuationCharacters)
- .isSuperset(of: CharacterSet(charactersIn: substring)) {
- stop.pointee = true
- return
- } else if CharacterSet.alphanumerics.isSuperset(of: CharacterSet(charactersIn: substring)) {
- hasFoundValidWordChar = true
- }
- rangeToDelete.length += substring.count
+// MARK: - Private TextSelection
- if delta < 0 {
- rangeToDelete.location -= substring.count
- }
+private extension TextSelectionManager.TextSelection {
+ func didInsertText(length: Int, retainLength: Bool = false) {
+ if !retainLength {
+ range.length = 0
}
-
- return rangeToDelete
+ range.location += length
}
+}
- /// Extends the selection by one line in the direction specified.
- ///
- /// If extending backwards, this method will return the beginning of the leading non-whitespace characters
- /// in the line. If the offset is located in the leading whitespace it will return the real line beginning.
- /// For Example
- /// ```
- /// ^ = offset, ^--^ = returned range
- /// Line:
- /// Loren Ipsum
- /// ^
- /// Extend 1st Call:
- /// Loren Ipsum
- /// ^-----^
- /// Extend 2nd Call:
- /// Loren Ipsum
- /// ^----^
- /// ```
- ///
- /// - Parameters:
- /// - string: The reference string to use.
- /// - offset: The location to start extending the selection from.
- /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards.
- /// - Returns: The range of the extended selection.
- private func extendSelectionLine(string: NSString, from offset: Int, delta: Int) -> NSRange {
- guard let line = layoutManager?.textLineForOffset(offset),
- let lineFragment = line.data.typesetter.lineFragments.getLine(atIndex: offset - line.range.location)
- else {
- return NSRange(location: offset, length: 0)
- }
- print(line.range, lineFragment.range)
- let lineBound = delta > 0
- ? line.range.location + lineFragment.range.max
- : line.range.location + lineFragment.range.location
-
- var foundRange = NSRange(
- location: min(lineBound, offset),
- length: max(lineBound, offset) - min(lineBound, offset)
- )
- let originalFoundRange = foundRange
+// MARK: - Text Storage Delegate
- // Only do this if we're going backwards.
- if delta < 0 {
- string.enumerateSubstrings(in: foundRange, options: .byCaretPositions) { substring, _, _, stop in
- if let substring = substring as String? {
- if CharacterSet.whitespacesWithoutNewlines.isSuperset(of: CharacterSet(charactersIn: substring)) {
- foundRange.location += 1
- foundRange.length -= 1
- } else {
- stop.pointee = true
- }
+extension TextSelectionManager: NSTextStorageDelegate {
+ func textStorage(
+ _ textStorage: NSTextStorage,
+ didProcessEditing editedMask: NSTextStorageEditActions,
+ range editedRange: NSRange,
+ changeInLength delta: Int
+ ) {
+ guard editedMask.contains(.editedCharacters) else { return }
+ for textSelection in textSelections {
+ if textSelection.range.max < editedRange.location {
+ textSelection.range.location += delta
+ textSelection.range.length = 0
+ } else if textSelection.range.intersection(editedRange) != nil {
+ if delta > 0 {
+ textSelection.range.location = editedRange.max
} else {
- stop.pointee = true
+ textSelection.range.location = editedRange.location
}
+ textSelection.range.length = 0
+ } else {
+ textSelection.range.length = 0
}
}
-
- return foundRange.length == 0 ? originalFoundRange : foundRange
- }
-
- /// Extends a selection one "container" long.
- /// - Parameters:
- /// - offset: The location to start extending the selection from.
- /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards.
- /// - Returns: The range of the extended selection.
- private func extendSelectionContainer(from offset: Int, delta: Int) -> NSRange {
- guard let layoutView, let endOffset = layoutManager?.textOffsetAtPoint(
- CGPoint(
- x: delta > 0 ? layoutView.frame.maxX : layoutView.frame.minX,
- y: delta > 0 ? layoutView.frame.maxY : layoutView.frame.minY
- )
- ) else {
- return NSRange(location: offset, length: 0)
- }
- return endOffset > offset
- ? NSRange(location: offset, length: endOffset - offset)
- : NSRange(location: endOffset, length: offset - endOffset)
+ updateSelectionViews()
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView+Delete.swift b/Sources/CodeEditTextView/TextView/TextView/TextView+Delete.swift
index ade97805f..c1c5d2845 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView+Delete.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView+Delete.swift
@@ -12,16 +12,43 @@ extension TextView {
delete(direction: .backward, destination: .character)
}
+ open override func deleteBackwardByDecomposingPreviousCharacter(_ sender: Any?) {
+ delete(direction: .backward, destination: .character, decomposeCharacters: true)
+ }
+
+ open override func deleteForward(_ sender: Any?) {
+ delete(direction: .forward, destination: .character)
+ }
+
open override func deleteWordBackward(_ sender: Any?) {
delete(direction: .backward, destination: .word)
}
+ open override func deleteWordForward(_ sender: Any?) {
+ delete(direction: .forward, destination: .word)
+ }
+
open override func deleteToBeginningOfLine(_ sender: Any?) {
delete(direction: .backward, destination: .line)
}
- private func delete(direction: TextSelectionManager.Direction, destination: TextSelectionManager.Destination) {
- print(#function, direction, destination)
+ open override func deleteToEndOfLine(_ sender: Any?) {
+ delete(direction: .forward, destination: .line)
+ }
+
+ open override func deleteToBeginningOfParagraph(_ sender: Any?) {
+ delete(direction: .backward, destination: .line)
+ }
+
+ open override func deleteToEndOfParagraph(_ sender: Any?) {
+ delete(direction: .forward, destination: .line)
+ }
+
+ private func delete(
+ direction: TextSelectionManager.Direction,
+ destination: TextSelectionManager.Destination,
+ decomposeCharacters: Bool = false
+ ) {
/// Extend each selection by a distance specified by `destination`, then update both storage and the selection.
for textSelection in selectionManager.textSelections {
let extendedRange = selectionManager.rangeOfSelection(
@@ -29,18 +56,11 @@ extension TextView {
direction: direction,
destination: destination
)
+ print(textSelection.range)
+ print(extendedRange, textSelection.range.union(extendedRange))
textSelection.range.formUnion(extendedRange)
}
replaceCharacters(in: selectionManager.textSelections.map(\.range), with: "")
-
- var delta: Int = 0
- for textSelection in selectionManager.textSelections {
- textSelection.range.location -= delta
- delta += textSelection.range.length
- textSelection.range.length = 0
- }
-
- selectionManager.updateSelectionViews()
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift b/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
index dfe21774f..8760047e0 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
@@ -23,7 +23,7 @@ import TextStory
this should only happen if the `setMarkedText` method is called with `NSNotFound` for the replacement range's
location (indicating that the marked text should appear at the insertion location)
- **Note: Visual studio code Does not correctly support marked text with multiple cursors,*
+ **Note: Visual studio code Does Not correctly support marked text with multiple cursors,*
**use Xcode as an example of this behavior.*
*/
@@ -58,20 +58,12 @@ extension TextView: NSTextInputClient {
if replacementRange.location == NSNotFound {
replaceCharacters(in: selectionManager.textSelections.map { $0.range }, with: insertString)
- // TODO: This actually won't fix the selection ranges. See the delete method
- selectionManager.textSelections.forEach { selection in
- selection.didInsertText(length: insertString.count == 0 ? -selection.range.length : insertString.count)
- }
} else {
replaceCharacters(in: replacementRange, with: insertString)
- selectionManager.updateSelections(
- delta: insertString.count == 0 ? -replacementRange.length : replacementRange.length
- )
}
textStorage.endEditing()
layoutManager.endTransaction()
- selectionManager?.updateSelectionViews()
}
// MARK: - Marked Text
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
index 944beab4e..c353c5012 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
@@ -91,7 +91,7 @@ class TextView: NSView, NSTextContent {
postsBoundsChangedNotifications = true
autoresizingMask = [.width, .height]
- // TODO: Implement typing/"default" attributes
+ // TODO: Implement typing/default attributes
textStorage.addAttributes([.font: font], range: documentRange)
layoutManager = TextLayoutManager(
@@ -113,6 +113,7 @@ class TextView: NSView, NSTextContent {
layoutView: self, // TODO: This is an odd syntax... consider reworking this
delegate: self
)
+ storageDelegate.addDelegate(selectionManager)
_undoManager = CEUndoManager(textView: self)
@@ -207,7 +208,8 @@ class TextView: NSView, NSTextContent {
guard isEditable else { return }
layoutManager.beginTransaction()
textStorage.beginEditing()
- for range in ranges where range.length != 0 {
+ // Can't insert an empty string into an empty range. One must be not empty
+ for range in ranges where !range.isEmpty || !string.isEmpty {
replaceCharactersNoCheck(in: range, with: string)
_undoManager?.registerMutation(
TextMutation(string: string as String, range: range, limit: textStorage.length)
diff --git a/TODO.md b/TODO.md
index bde16b278..104e30575 100644
--- a/TODO.md
+++ b/TODO.md
@@ -17,7 +17,7 @@
- [x] Delete line
- [x] Delete word
- [x] Delete character
- - [x] Delete across line boundaries
+ - [] Delete across line boundaries
- [x] Paste
- [x] Line Numbers
- [] select text
From 793bf1015b2d7878b6db7dcdeab4fe75c89fdc09 Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Sat, 26 Aug 2023 08:08:30 -0500
Subject: [PATCH 28/75] Small refactor
---
.../TextLayoutManager/TextLayoutManager.swift | 9 ++---
.../TextView/TextLayoutManager/TextLine.swift | 10 ++---
.../TextLineStorage+NSTextStorage.swift | 22 ++++++-----
...on.swift => TextLineStorage+Structs.swift} | 5 +++
.../TextLineStorage/TextLineStorage.swift | 8 ++--
.../TextView/Utils/LineEnding.swift | 6 ++-
.../TextLayoutLineStorageTests.swift | 38 ++++++++-----------
7 files changed, 48 insertions(+), 50 deletions(-)
rename Sources/CodeEditTextView/TextView/TextLineStorage/{TextLineStorage+Position.swift => TextLineStorage+Structs.swift} (95%)
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
index 04fe6e150..e948c4213 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -79,21 +79,17 @@ final class TextLayoutManager: NSObject {
/// Parses the text storage object into lines and builds the `lineStorage` object from those lines.
private func prepareTextLines() {
guard lineStorage.count == 0 else { return }
-#if DEBUG
var info = mach_timebase_info()
guard mach_timebase_info(&info) == KERN_SUCCESS else { return }
let start = mach_absolute_time()
-#endif
lineStorage.buildFromTextStorage(textStorage, estimatedLineHeight: estimateLineHeight())
- detectedLineEnding = LineEnding.detectLineEnding(lineStorage: lineStorage)
+ detectedLineEnding = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: textStorage)
-#if DEBUG
let end = mach_absolute_time()
let elapsed = end - start
let nanos = elapsed * UInt64(info.numer) / UInt64(info.denom)
print("Text Layout Manager built in: ", TimeInterval(nanos) / TimeInterval(NSEC_PER_MSEC), "ms")
-#endif
}
internal func estimateLineHeight() -> CGFloat {
@@ -311,7 +307,8 @@ final class TextLayoutManager: NSObject {
line.prepareForDisplay(
maxWidth: maxWidth,
lineHeightMultiplier: lineHeightMultiplier,
- range: position.range
+ range: position.range,
+ stringRef: textStorage
)
var height: CGFloat = 0
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
index 9e6c31773..fa1fa8bb0 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
@@ -11,15 +11,11 @@ import AppKit
/// Represents a displayable line of text.
final class TextLine: Identifiable {
let id: UUID = UUID()
- weak var stringRef: NSTextStorage?
+// private weak var stringRef: NSTextStorage?
private var needsLayout: Bool = true
var maxWidth: CGFloat?
private(set) var typesetter: Typesetter = Typesetter()
- init(stringRef: NSTextStorage) {
- self.stringRef = stringRef
- }
-
func setNeedsLayout() {
needsLayout = true
typesetter = Typesetter()
@@ -29,8 +25,8 @@ final class TextLine: Identifiable {
needsLayout || maxWidth != self.maxWidth
}
- func prepareForDisplay(maxWidth: CGFloat, lineHeightMultiplier: CGFloat, range: NSRange) {
- guard let string = stringRef?.attributedSubstring(from: range) else { return }
+ func prepareForDisplay(maxWidth: CGFloat, lineHeightMultiplier: CGFloat, range: NSRange, stringRef: NSTextStorage) {
+ let string = stringRef.attributedSubstring(from: range)
self.maxWidth = maxWidth
typesetter.prepareToTypeset(
string,
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+NSTextStorage.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+NSTextStorage.swift
index 25e78ebbd..3a29b14b8 100644
--- a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+NSTextStorage.swift
+++ b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+NSTextStorage.swift
@@ -26,20 +26,24 @@ extension TextLineStorage where Data == TextLine {
}
var index = 0
- var lines: [(TextLine, Int)] = []
+ var lines: [BuildItem] = []
while let range = getNextLine(startingAt: index) {
- lines.append((
- TextLine(stringRef: textStorage),
- range.max - index
- ))
+ lines.append(
+ BuildItem(
+ data: TextLine(),
+ length: range.max - index
+ )
+ )
index = NSMaxRange(range)
}
// Create the last line
if textStorage.length - index > 0 {
- lines.append((
- TextLine(stringRef: textStorage),
- textStorage.length - index
- ))
+ lines.append(
+ BuildItem(
+ data: TextLine(),
+ length: textStorage.length - index
+ )
+ )
}
// Use an efficient tree building algorithm rather than adding lines sequentially
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Position.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Structs.swift
similarity index 95%
rename from Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Position.swift
rename to Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Structs.swift
index 13b7dd8f6..6515d14dc 100644
--- a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Position.swift
+++ b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Structs.swift
@@ -47,4 +47,9 @@ extension TextLineStorage where Data: Identifiable {
/// The index of the node in the document.
let index: Int
}
+
+ struct BuildItem {
+ let data: Data
+ let length: Int
+ }
}
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
index 286a0b9c6..96fe308dd 100644
--- a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
+++ b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
@@ -276,7 +276,7 @@ final class TextLineStorage {
/// Efficiently builds the tree from the given array of lines.
/// - Parameter lines: The lines to use to build the tree.
- public func build(from lines: [(Data, Int)], estimatedLineHeight: CGFloat) {
+ public func build(from lines: [BuildItem], estimatedLineHeight: CGFloat) {
root = build(lines: lines, estimatedLineHeight: estimatedLineHeight, left: 0, right: lines.count, parent: nil).0
count = lines.count
}
@@ -290,7 +290,7 @@ final class TextLineStorage {
/// - parent: The parent of the subtree, `nil` if this is the root.
/// - Returns: A node, if available, along with it's subtree's height and offset.
private func build(
- lines: [(Data, Int)],
+ lines: [BuildItem],
estimatedLineHeight: CGFloat,
left: Int,
right: Int,
@@ -299,8 +299,8 @@ final class TextLineStorage {
guard left < right else { return (nil, nil, nil, 0) }
let mid = left + (right - left)/2
let node = Node(
- length: lines[mid].1,
- data: lines[mid].0,
+ length: lines[mid].length,
+ data: lines[mid].data,
leftSubtreeOffset: 0,
leftSubtreeHeight: 0,
leftSubtreeCount: 0,
diff --git a/Sources/CodeEditTextView/TextView/Utils/LineEnding.swift b/Sources/CodeEditTextView/TextView/Utils/LineEnding.swift
index a54dfd543..3ebb7181f 100644
--- a/Sources/CodeEditTextView/TextView/Utils/LineEnding.swift
+++ b/Sources/CodeEditTextView/TextView/Utils/LineEnding.swift
@@ -5,6 +5,8 @@
// Created by Khan Winter on 8/16/23.
//
+import AppKit
+
enum LineEnding: String {
/// The default unix `\n` character
case lf = "\n"
@@ -35,7 +37,7 @@ enum LineEnding: String {
/// Attempts to detect the line ending from a line storage.
/// - Parameter lineStorage: The line storage to enumerate.
/// - Returns: A line ending. Defaults to `.lf` if none could be found.
- static func detectLineEnding(lineStorage: TextLineStorage) -> LineEnding {
+ static func detectLineEnding(lineStorage: TextLineStorage, textStorage: NSTextStorage) -> LineEnding {
var histogram: [LineEnding: Int] = [
.lf: 0,
.cr: 0,
@@ -45,7 +47,7 @@ enum LineEnding: String {
var lineIterator = lineStorage.makeIterator()
while let line = lineIterator.next(), shouldContinue {
- guard let lineString = line.data.stringRef?.substring(from: line.range),
+ guard let lineString = textStorage.substring(from: line.range),
let lineEnding = LineEnding(line: lineString) else {
continue
}
diff --git a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
index 2eb83c979..e2c9b47a8 100644
--- a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
+++ b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
@@ -4,11 +4,10 @@ import XCTest
final class TextLayoutLineStorageTests: XCTestCase {
func test_insert() {
let tree = TextLineStorage()
- let stringRef = NSTextStorage(string: "")
var sum = 0
for i in 0..<20 {
tree.insert(
- line: .init(stringRef: stringRef),
+ line: TextLine(),
atIndex: sum,
length: i + 1,
height: 1.0
@@ -21,11 +20,10 @@ final class TextLayoutLineStorageTests: XCTestCase {
func test_update() {
let tree = TextLineStorage()
- let stringRef = NSTextStorage(string: "")
var sum = 0
for i in 0..<20 {
tree.insert(
- line: .init(stringRef: stringRef),
+ line: TextLine(),
atIndex: sum,
length: i + 1,
height: 1.0
@@ -39,12 +37,11 @@ final class TextLayoutLineStorageTests: XCTestCase {
func test_insertPerformance() {
let tree = TextLineStorage()
- let stringRef = NSTextStorage(string: "")
- var lines: [(TextLine, Int)] = []
+ var lines: [TextLineStorage.BuildItem] = []
for i in 0..<250_000 {
- lines.append((
- TextLine(stringRef: stringRef),
- i + 1
+ lines.append(TextLineStorage.BuildItem(
+ data: TextLine(),
+ length: i + 1
))
}
tree.build(from: lines, estimatedLineHeight: 1.0)
@@ -52,7 +49,7 @@ final class TextLayoutLineStorageTests: XCTestCase {
measure {
for _ in 0..<100_000 {
tree.insert(
- line: .init(stringRef: stringRef), atIndex: Int.random(in: 0..()
- let stringRef = NSTextStorage(string: "")
measure {
- var lines: [(TextLine, Int)] = []
- for i in 0..<250_000 {
- lines.append((
- TextLine(stringRef: stringRef),
- i + 1
- ))
+ var lines: [TextLineStorage.BuildItem] = (0..<250_000).map {
+ TextLineStorage.BuildItem(
+ data: TextLine(),
+ length: $0 + 1
+ )
}
tree.build(from: lines, estimatedLineHeight: 1.0)
}
@@ -75,12 +70,11 @@ final class TextLayoutLineStorageTests: XCTestCase {
func test_iterationPerformance() {
let tree = TextLineStorage()
- let stringRef = NSTextStorage(string: "")
- var lines: [(TextLine, Int)] = []
+ var lines: [TextLineStorage.BuildItem] = []
for i in 0..<100_000 {
- lines.append((
- TextLine(stringRef: stringRef),
- i + 1
+ lines.append(TextLineStorage.BuildItem(
+ data: TextLine(),
+ length: i + 1
))
}
tree.build(from: lines, estimatedLineHeight: 1.0)
From 377c986be88c13b938a1fef724d5f17997288b7e Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Sat, 26 Aug 2023 11:19:07 -0500
Subject: [PATCH 29/75] Fix TODO, Fix Performance Test
---
.../TextView/TextView/TextView.swift | 2 +-
TODO.md | 60 +++++++++----------
.../TextLayoutLineStorageTests.swift | 12 ++--
3 files changed, 37 insertions(+), 37 deletions(-)
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
index c353c5012..fb2def04a 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
@@ -19,7 +19,7 @@ import TextStory
| | |-> [LineFragment] Represents a visual text line, stored in a line storage for long lines
| |-> [LineFragmentView] Reusable line fragment view that draws a line fragment.
|
- |-> TextSelectionManager (depends on LayoutManager) Maintains and renders text selections
+ |-> TextSelectionManager Maintains, modifies, and renders text selections
| |-> [TextSelection]
```
*/
diff --git a/TODO.md b/TODO.md
index 104e30575..79a69d73d 100644
--- a/TODO.md
+++ b/TODO.md
@@ -10,40 +10,40 @@
- [X] resize correctly
- [x] syntax highlighting
- [x] cursor
-- [] edit text
+- [ ] edit text
- [x] isEditable
- [x] Insert
- - [] Delete
+ - [ ] Delete
- [x] Delete line
- [x] Delete word
- [x] Delete character
- - [] Delete across line boundaries
+ - [ ] Delete across line boundaries
- [x] Paste
- [x] Line Numbers
-- [] select text
- - [] Copy
-- [] multiple cursors (+ edit)
-- [] Keyboard navigation
- - [] Arrow keys
- - [] Command & control arrow keys
- - [] Page up and down
-- [] tab widths & indents
-- [] parameter updating
- - [] tab & indent options
- - [] kern
- - [] theme
- - [] line height
- - [] wrap lines
- - [] editor overscroll
- - [] useThemeBackground
- - [] highlight provider
- - [] content insets
- - [] isEditable
- - [] isSelectable
- - [] language
-- [] undo/redo
-- [] sync system appearance
-- [] update text (from outside)
-- [] highlight brackets
-- [] textformation integration
-- [] make non scrollable (just remove scroll view, add gutter above textview & set width )
+- [ ] select text
+ - [ ] Copy
+- [ ] multiple cursors (+ edit)
+- [ ] Keyboard navigation
+ - [ ] Arrow keys
+ - [ ] Command & control arrow keys
+ - [ ] Page up and down
+- [ ] tab widths & indents
+- [ ] parameter updating
+ - [ ] tab & indent options
+ - [ ] kern
+ - [ ] theme
+ - [ ] line height
+ - [ ] wrap lines
+ - [ ] editor overscroll
+ - [ ] useThemeBackground
+ - [ ] highlight provider
+ - [ ] content insets
+ - [ ] isEditable
+ - [ ] isSelectable
+ - [ ] language
+- [ ] undo/redo
+- [ ] sync system appearance
+- [ ] update text (from outside)
+- [ ] highlight brackets
+- [ ] textformation integration
+- [ ] make non scrollable (just remove scroll view, add gutter above textview & set width )
diff --git a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
index e2c9b47a8..4543a5fa5 100644
--- a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
+++ b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
@@ -57,13 +57,13 @@ final class TextLayoutLineStorageTests: XCTestCase {
func test_insertFastPerformance() {
let tree = TextLineStorage()
+ let lines: [TextLineStorage.BuildItem] = (0..<250_000).map {
+ TextLineStorage.BuildItem(
+ data: TextLine(),
+ length: $0 + 1
+ )
+ }
measure {
- var lines: [TextLineStorage.BuildItem] = (0..<250_000).map {
- TextLineStorage.BuildItem(
- data: TextLine(),
- length: $0 + 1
- )
- }
tree.build(from: lines, estimatedLineHeight: 1.0)
}
}
From 8d877cca85a142bb4d6e49c64cbbf471773600ce Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Thu, 31 Aug 2023 01:38:55 -0500
Subject: [PATCH 30/75] Add RB Tree Delete, lazy update cursors
---
.../TextLineStorage+Node.swift | 24 +-
.../TextLineStorage/TextLineStorage.swift | 339 +++++++-----------
.../TextSelectionManager.swift | 28 +-
TODO.md | 49 ---
.../TextLayoutLineStorageTests.swift | 64 ++++
5 files changed, 213 insertions(+), 291 deletions(-)
delete mode 100644 TODO.md
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift
index 07e1b12a6..e06feace7 100644
--- a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift
+++ b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift
@@ -10,20 +10,12 @@ import Foundation
extension TextLineStorage {
@inlinable
func isRightChild(_ node: Node) -> Bool {
- node.parent?.right == node
+ node.parent?.right === node
}
@inlinable
func isLeftChild(_ node: Node) -> Bool {
- node.parent?.left == node
- }
-
- func sibling(_ node: Node) -> Node? {
- if isLeftChild(node) {
- return node.parent?.right
- } else {
- return node.parent?.left
- }
+ node.parent?.left === node
}
/// Transplants a node with another node.
@@ -56,7 +48,7 @@ extension TextLineStorage {
nodeV?.parent = nodeU.parent
}
- final class Node: Equatable {
+ final class Node {
enum Color {
case red
case black
@@ -104,8 +96,12 @@ extension TextLineStorage {
self.color = color
}
- static func == (lhs: Node, rhs: Node) -> Bool {
- lhs.data.id == rhs.data.id
+ func sibling() -> Node? {
+ if parent?.left === self {
+ return parent?.right
+ } else {
+ return parent?.left
+ }
}
func minimum() -> Node {
@@ -132,7 +128,7 @@ extension TextLineStorage {
// Else go upward until node is a left child
var currentNode = self
var parent = currentNode.parent
- while currentNode.parent?.right == currentNode {
+ while currentNode.parent?.right === currentNode {
if let parent = parent {
currentNode = parent
}
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
index 96fe308dd..859ea70b5 100644
--- a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
+++ b/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
@@ -9,6 +9,12 @@ import Foundation
/// Implements a red-black tree for efficiently editing, storing and retrieving lines of text in a document.
final class TextLineStorage {
+ private enum MetaFixupAction {
+ case inserted
+ case deleted
+ case none
+ }
+
internal var root: Node?
/// The number of characters in the storage object.
@@ -31,10 +37,7 @@ final class TextLineStorage {
// TODO: Cache this value & update on tree update
var last: TextLinePosition? {
- guard length > 0 else { return nil }
- guard let position = search(for: length - 1) else {
- return nil
- }
+ guard length > 0, let position = search(for: length - 1) else { return nil }
return TextLinePosition(position: position)
}
@@ -176,7 +179,7 @@ final class TextLineStorage {
height += deltaHeight
position.node.length += delta
position.node.height += deltaHeight
- metaFixup(startingAt: position.node, delta: delta, deltaHeight: deltaHeight, insertedNode: false)
+ metaFixup(startingAt: position.node, delta: delta, deltaHeight: deltaHeight)
}
/// Deletes the line containing the given index.
@@ -191,66 +194,10 @@ final class TextLineStorage {
return
}
guard let node = search(for: index)?.node else { return }
- defer {
- count -= 1
- }
-
- var originalColor = node.color
- // Node to slice out
- var nodeY: Node = node
- // Node that replaces the sliced node.
- var nodeX: Node?
-
- if node.left == nil {
- nodeX = node.right
- transplant(node, with: node.right)
- } else if node.right == nil {
- nodeX = node.left
- transplant(node, with: node.left)
- } else {
- nodeY = node.right!.minimum() // node.right is not null by case 2
- originalColor = nodeY.color
- nodeX = nodeY.right
- if nodeY.parent == node {
- nodeX?.parent = nodeY
- } else {
- transplant(nodeY, with: nodeY.right)
- nodeY.right = node.right
- nodeY.right?.parent = nodeY
- }
-
- transplant(node, with: nodeY)
- nodeY.left = node.left
- nodeY.left?.parent = nodeY
- nodeY.color = node.color
- }
-
-// if (z.left == TNULL) {
-// x = z.right;
-// rbTransplant(z, z.right);
-// } else if (z.right == TNULL) {
-// x = z.left;
-// rbTransplant(z, z.left);
-// } else {
-// y = minimum(z.right);
-// yOriginalColor = y.color;
-// x = y.right;
-// if (y.parent == z) {
-// x.parent = y;
-// } else {
-// rbTransplant(y, y.right);
-// y.right = z.right;
-// y.right.parent = y;
-// }
-//
-// rbTransplant(z, y);
-// y.left = z.left;
-// y.left.parent = y;
-// y.color = z.color;
-// }
-// if (yOriginalColor == 0) {
-// fixDelete(x);
-// }
+ count -= 1
+ length -= node.length
+ height -= node.height
+ deleteNode(node)
}
public func removeAll() {
@@ -260,20 +207,6 @@ final class TextLineStorage {
height = 0
}
- public func printTree() {
- print(
- treeString(root!) { node in
- (
- // swiftlint:disable:next line_length
- "\(node.length)[\(node.leftSubtreeOffset)\(node.color == .red ? "R" : "B")][\(node.height), \(node.leftSubtreeHeight)]",
- node.left,
- node.right
- )
- }
- )
- print("")
- }
-
/// Efficiently builds the tree from the given array of lines.
/// - Parameter lines: The lines to use to build the tree.
public func build(from lines: [BuildItem], estimatedLineHeight: CGFloat) {
@@ -371,21 +304,53 @@ private extension TextLineStorage {
currentYPosition += node.height + (node.right?.leftSubtreeHeight ?? 0)
currentIndex += 1 + (node.right?.leftSubtreeCount ?? 0)
} else {
+ print(index, currentOffset, node.length)
currentNode = nil
}
}
-
return nil
}
+ // MARK: - Delete
+
+ func deleteNode(_ node: Node) {
+ if node.left != nil, let nodeRight = node.right {
+ // Both children exist, replace with min of right
+ let replacementNode = nodeRight.minimum()
+ deleteNode(replacementNode)
+ transplant(node, with: replacementNode)
+ node.left?.parent = replacementNode
+ node.right?.parent = replacementNode
+ replacementNode.left = node.left
+ replacementNode.right = node.right
+ replacementNode.color = node.color
+ replacementNode.leftSubtreeCount = node.leftSubtreeCount
+ replacementNode.leftSubtreeHeight = node.leftSubtreeHeight
+ replacementNode.leftSubtreeOffset = node.leftSubtreeOffset
+ metaFixup(startingAt: replacementNode, delta: -node.length, deltaHeight: -node.height, nodeAction: .deleted)
+ } else {
+ // Either node's left or right is `nil`
+ metaFixup(startingAt: node, delta: -node.length, deltaHeight: -node.height, nodeAction: .deleted)
+ let replacementNode = node.left ?? node.right
+ transplant(node, with: replacementNode)
+ if node.color == .black {
+ if replacementNode != nil && replacementNode?.color == .red {
+ replacementNode?.color = .black
+ } else if let replacementNode {
+ deleteFixup(node: replacementNode)
+ }
+ }
+ }
+ }
+
// MARK: - Fixup
func insertFixup(node: Node) {
- metaFixup(startingAt: node, delta: node.length, deltaHeight: node.height, insertedNode: true)
+ metaFixup(startingAt: node, delta: node.length, deltaHeight: node.height, nodeAction: .inserted)
var nextNode: Node? = node
- while var nodeX = nextNode, nodeX != root, let nodeXParent = nodeX.parent, nodeXParent.color == .red {
- let nodeY = sibling(nodeXParent)
+ while var nodeX = nextNode, nodeX !== root, let nodeXParent = nodeX.parent, nodeXParent.color == .red {
+ let nodeY = nodeXParent.sibling()
if isLeftChild(nodeXParent) {
if nodeY?.color == .red {
nodeXParent.color = .black
@@ -428,20 +393,89 @@ private extension TextLineStorage {
root?.color = .black
}
- /// RB Tree Deletes `:(`
+ // swiftlint:disable:next cyclomatic_complexity
func deleteFixup(node: Node) {
+ guard node.parent != nil, node.color == .black, var sibling = node.sibling() else { return }
+ // Case 1: Sibling is red
+ if sibling.color == .red {
+ // Recolor
+ sibling.color = .black
+ if let nodeParent = node.parent {
+ nodeParent.color = .red
+ if isLeftChild(node) {
+ leftRotate(node: nodeParent)
+ } else {
+ rightRotate(node: nodeParent)
+ }
+ if let newSibling = node.sibling() {
+ sibling = newSibling
+ }
+ }
+ }
+
+ // Case 2: Sibling is black with two black children
+ if sibling.left?.color == .black && sibling.right?.color == .black {
+ sibling.color = .red
+ if let nodeParent = node.parent {
+ deleteFixup(node: nodeParent)
+ }
+ } else {
+ // Case 3: Sibling black with one black child
+ if sibling.left?.color == .black || sibling.right?.color == .black {
+ let isLeftBlack = sibling.left?.color == .black
+ let siblingOtherChild = isLeftBlack ? sibling.right : sibling.left
+ sibling.color = .red
+ siblingOtherChild?.color = .black
+ if isLeftBlack {
+ leftRotate(node: sibling)
+ } else {
+ rightRotate(node: sibling)
+ }
+ if let newSibling = node.sibling() {
+ sibling = newSibling
+ }
+ }
+ // Case 4: Sibling is black with red child
+ if let nodeParent = node.parent {
+ sibling.color = nodeParent.color
+ nodeParent.color = .black
+ if isLeftChild(node) {
+ sibling.right?.color = .black
+ leftRotate(node: nodeParent)
+ } else {
+ sibling.left?.color = .black
+ rightRotate(node: nodeParent)
+ }
+ root?.color = .black
+ return
+ }
+ }
+ node.color = .black
}
/// Walk up the tree, updating any `leftSubtree` metadata.
- func metaFixup(startingAt node: Node, delta: Int, deltaHeight: CGFloat, insertedNode: Bool) {
+ private func metaFixup(
+ startingAt node: Node,
+ delta: Int,
+ deltaHeight: CGFloat,
+ nodeAction: MetaFixupAction = .none
+ ) {
guard node.parent != nil else { return }
var node: Node? = node
- while node != nil, node != root {
+ while node != nil, node !== root {
if isLeftChild(node!) {
node?.parent?.leftSubtreeOffset += delta
node?.parent?.leftSubtreeHeight += deltaHeight
- node?.parent?.leftSubtreeCount += insertedNode ? 1 : 0
+ switch nodeAction {
+ case .inserted:
+ node?.parent?.leftSubtreeCount += 1
+ case .deleted:
+ node?.parent?.leftSubtreeCount -= 1
+ case .none:
+ node = node?.parent
+ continue
+ }
}
node = node?.parent
}
@@ -502,134 +536,3 @@ private extension TextLineStorage {
node.parent = nodeY
}
}
-
-// swiftlint:disable all
-// Awesome tree printing function from https://stackoverflow.com/a/43903427/10453550
-public func treeString(_ node:T, reversed:Bool=false, isTop:Bool=true, using nodeInfo:(T)->(String,T?,T?)) -> String {
- // node value string and sub nodes
- let (stringValue, leftNode, rightNode) = nodeInfo(node)
-
- let stringValueWidth = stringValue.count
-
- // recurse to sub nodes to obtain line blocks on left and right
- let leftTextBlock = leftNode == nil ? []
- : treeString(leftNode!,reversed:reversed,isTop:false,using:nodeInfo)
- .components(separatedBy:"\n")
-
- let rightTextBlock = rightNode == nil ? []
- : treeString(rightNode!,reversed:reversed,isTop:false,using:nodeInfo)
- .components(separatedBy:"\n")
-
- // count common and maximum number of sub node lines
- let commonLines = min(leftTextBlock.count,rightTextBlock.count)
- let subLevelLines = max(rightTextBlock.count,leftTextBlock.count)
-
- // extend lines on shallower side to get same number of lines on both sides
- let leftSubLines = leftTextBlock
- + Array(repeating:"", count: subLevelLines-leftTextBlock.count)
- let rightSubLines = rightTextBlock
- + Array(repeating:"", count: subLevelLines-rightTextBlock.count)
-
- // compute location of value or link bar for all left and right sub nodes
- // * left node's value ends at line's width
- // * right node's value starts after initial spaces
- let leftLineWidths = leftSubLines.map{$0.count}
- let rightLineIndents = rightSubLines.map{$0.prefix{$0==" "}.count }
-
- // top line value locations, will be used to determine position of current node & link bars
- let firstLeftWidth = leftLineWidths.first ?? 0
- let firstRightIndent = rightLineIndents.first ?? 0
-
-
- // width of sub node link under node value (i.e. with slashes if any)
- // aims to center link bars under the value if value is wide enough
- //
- // ValueLine: v vv vvvvvv vvvvv
- // LinkLine: / \ / \ / \ / \
- //
- let linkSpacing = min(stringValueWidth, 2 - stringValueWidth % 2)
- let leftLinkBar = leftNode == nil ? 0 : 1
- let rightLinkBar = rightNode == nil ? 0 : 1
- let minLinkWidth = leftLinkBar + linkSpacing + rightLinkBar
- let valueOffset = (stringValueWidth - linkSpacing) / 2
-
- // find optimal position for right side top node
- // * must allow room for link bars above and between left and right top nodes
- // * must not overlap lower level nodes on any given line (allow gap of minSpacing)
- // * can be offset to the left if lower subNodes of right node
- // have no overlap with subNodes of left node
- let minSpacing = 2
- let rightNodePosition = zip(leftLineWidths,rightLineIndents[0..()
+ func createTree() -> TextLineStorage {
+ let tree = TextLineStorage()
+ var data = [TextLineStorage.BuildItem]()
+ for i in 0..<15 {
+ data.append(.init(data: TextLine(), length: i + 1))
+ }
+ tree.build(from: data, estimatedLineHeight: 1.0)
+ return tree
+ }
+
+ // Single Element
+ tree.insert(line: TextLine(), atIndex: 0, length: 1, height: 1.0)
+ XCTAssert(tree.length == 1, "Tree length incorrect")
+ tree.delete(lineAt: 0)
+ XCTAssert(tree.length == 0, "Tree failed to delete single node")
+ XCTAssert(tree.root == nil, "Tree root should be nil")
+
+ // Delete first
+
+ tree = createTree()
+// tree.printTree()
+ tree.delete(lineAt: 0)
+// print(tree.first)
+// tree.printTree()
+ XCTAssert(tree.count == 14, "Tree length incorrect")
+ XCTAssert(tree.first?.range.length == 2, "Failed to delete leftmost node")
+
+ // Delete last
+
+ tree = createTree()
+ tree.delete(lineAt: tree.length - 1)
+ XCTAssert(tree.count == 14, "Tree length incorrect")
+ XCTAssert(tree.last?.range.length == 14, "Failed to delete rightmost node")
+
+ // Delete mid leaf
+
+ tree = createTree()
+ tree.delete(lineAt: tree.length/2)
+ XCTAssert(tree.root?.right?.left?.right == nil, "Failed to delete node 11")
+ XCTAssert((tree.root?.right?.left?.left?.length ?? 0) == 9, "Left node of parent of deleted node is incorrect.")
+ XCTAssert(tree.count == 14, "Tree length incorrect")
+
+ // Delete root
+
+ tree = createTree()
+ tree.delete(lineAt: tree.root!.leftSubtreeOffset + 1)
+ XCTAssert(tree.root?.color == .black, "Root color incorrect")
+ XCTAssert(tree.root?.right?.left?.left == nil, "Replacement node was not moved to root")
+ XCTAssert(tree.root?.leftSubtreeCount == 7, "Replacement node was not given correct metadata.")
+ XCTAssert(tree.root?.leftSubtreeHeight == 7.0, "Replacement node was not given correct metadata.")
+ XCTAssert(tree.root?.leftSubtreeOffset == 28, "Replacement node was not given correct metadata.")
+ XCTAssert(tree.count == 14, "Tree length incorrect")
+
+ // Delete a bunch of random
+
+ for _ in 0..<20 {
+ tree = createTree()
+ tree.delete(lineAt: Int.random(in: 0..()
var lines: [TextLineStorage.BuildItem] = []
From a5f077e6df235084a924712c6759fd34bcf02f71 Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Thu, 31 Aug 2023 01:40:08 -0500
Subject: [PATCH 31/75] Add small test
---
Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
index d29c90c85..463a065c9 100644
--- a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
+++ b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
@@ -96,6 +96,11 @@ final class TextLayoutLineStorageTests: XCTestCase {
tree = createTree()
tree.delete(lineAt: Int.random(in: 0.. last, "Out of order after deletion")
+ last = line.range.length
+ }
}
}
From 63cff4808cb03dde9d77c690293f59ea05f5e0ec Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Sun, 3 Sep 2023 12:17:45 -0500
Subject: [PATCH 32/75] Begin `TextLayoutManager` updates for editing
---
.../TextLayoutManager/TextLayoutManager.swift | 31 ++++++++++++++++---
.../TextView/TextView/TextView+Delete.swift | 4 +--
.../TextView/TextView+NSTextInput.swift | 13 ++++----
.../TextView/TextView/TextView.swift | 5 ++-
.../TextView/Utils/LineEnding.swift | 3 +-
.../TextLayoutLineStorageTests.swift | 3 --
6 files changed, 39 insertions(+), 20 deletions(-)
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
index e948c4213..e7cf4ac7a 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
@@ -210,10 +210,13 @@ final class TextLayoutManager: NSObject {
visibleLineIds.removeAll(keepingCapacity: true)
}
+ /// Begins a transaction, preventing the layout manager from performing layout until the `endTransaction` is called.
+ /// Useful for grouping attribute modifications into one layout pass rather than laying out every update.
func beginTransaction() {
isInTransaction = true
}
+ /// Ends a transaction. When called, the layout manager will layout any necessary lines.
func endTransaction() {
isInTransaction = false
setNeedsLayout()
@@ -353,16 +356,36 @@ final class TextLayoutManager: NSObject {
// MARK: - Edits
extension TextLayoutManager: NSTextStorageDelegate {
+ /// Notifies the layout manager of an edit.
+ ///
+ /// Used by the `TextView` to tell the layout manager about any edits that will happen.
+ /// Use this to keep the layout manager's line storage in sync with the text storage.
+ ///
+ /// - Parameters:
+ /// - range: The range of the edit.
+ /// - string: The string to replace in the given range.
+ public func willReplaceCharactersInRange(range: NSRange, with string: String) {
+ print(textStorage.substring(from: range)!, string.isEmpty)
+ // Loop through each line being replaced, updating and removing where necessary.
+ for linePosition in lineStorage.linesInRange(range) {
+ // Two cases: Edited line, deleted line entirely
+
+ }
+
+ // Loop through each line being inserted, inserting where necessary
+ }
+
+ /// This method is to simplify keeping the layout manager in sync with attribute changes in the storage object.
+ /// This does not handle cases where characters have been inserted or removed from the storage.
+ /// For that, see the `willPerformEdit` method.
func textStorage(
_ textStorage: NSTextStorage,
didProcessEditing editedMask: NSTextStorageEditActions,
range editedRange: NSRange,
changeInLength delta: Int
) {
- if editedMask.contains(.editedCharacters) {
- lineStorage.update(atIndex: editedRange.location, delta: delta, deltaHeight: 0)
- // TODO: If delta < 0, handle delete.
+ if editedMask.contains(.editedAttributes) && delta == 0 {
+ invalidateLayoutForRange(editedRange)
}
- invalidateLayoutForRange(editedRange)
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView+Delete.swift b/Sources/CodeEditTextView/TextView/TextView/TextView+Delete.swift
index c1c5d2845..7a7239f15 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView+Delete.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView+Delete.swift
@@ -56,11 +56,9 @@ extension TextView {
direction: direction,
destination: destination
)
- print(textSelection.range)
- print(extendedRange, textSelection.range.union(extendedRange))
textSelection.range.formUnion(extendedRange)
}
-
+ print(#function, selectionManager.textSelections.map(\.range))
replaceCharacters(in: selectionManager.textSelections.map(\.range), with: "")
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift b/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
index 8760047e0..4b708214d 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
@@ -42,10 +42,8 @@ extension TextView: NSTextInputClient {
/// - replacementRange: The range of content to replace in the receiver’s text storage.
@objc public func insertText(_ string: Any, replacementRange: NSRange) {
guard isEditable else { return }
- layoutManager.beginTransaction()
- textStorage.beginEditing()
- let insertString: String
+ var insertString: String
switch string {
case let string as NSString:
insertString = string as String
@@ -56,14 +54,15 @@ extension TextView: NSTextInputClient {
assertionFailure("\(#function) called with invalid string type. Expected String or NSAttributedString.")
}
+ if LineEnding(rawValue: insertString) == .cr && layoutManager.detectedLineEnding == .crlf {
+ insertString = LineEnding.crlf.rawValue
+ }
+
if replacementRange.location == NSNotFound {
- replaceCharacters(in: selectionManager.textSelections.map { $0.range }, with: insertString)
+ replaceCharacters(in: selectionManager.textSelections.map(\.range), with: insertString)
} else {
replaceCharacters(in: replacementRange, with: insertString)
}
-
- textStorage.endEditing()
- layoutManager.endTransaction()
}
// MARK: - Marked Text
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
index fb2def04a..ad940dd7b 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView.swift
+++ b/Sources/CodeEditTextView/TextView/TextView/TextView.swift
@@ -204,6 +204,8 @@ class TextView: NSView, NSTextContent {
}
}
+ // MARK: - Replace Characters
+
public func replaceCharacters(in ranges: [NSRange], with string: String) {
guard isEditable else { return }
layoutManager.beginTransaction()
@@ -215,8 +217,8 @@ class TextView: NSView, NSTextContent {
TextMutation(string: string as String, range: range, limit: textStorage.length)
)
}
- textStorage.endEditing()
layoutManager.endTransaction()
+ textStorage.endEditing()
}
public func replaceCharacters(in range: NSRange, with string: String) {
@@ -224,6 +226,7 @@ class TextView: NSView, NSTextContent {
}
private func replaceCharactersNoCheck(in range: NSRange, with string: String) {
+ layoutManager.willReplaceCharactersInRange(range: range, with: string)
textStorage.replaceCharacters(in: range, with: string)
}
diff --git a/Sources/CodeEditTextView/TextView/Utils/LineEnding.swift b/Sources/CodeEditTextView/TextView/Utils/LineEnding.swift
index 3ebb7181f..0d9e42f2f 100644
--- a/Sources/CodeEditTextView/TextView/Utils/LineEnding.swift
+++ b/Sources/CodeEditTextView/TextView/Utils/LineEnding.swift
@@ -10,14 +10,13 @@ import AppKit
enum LineEnding: String {
/// The default unix `\n` character
case lf = "\n"
- /// MacOS Line ending `\r` character
+ /// MacOS line ending `\r` character
case cr = "\r"
/// Windows line ending sequence `\r\n`
case crlf = "\r\n"
/// Initialize a line ending from a line string.
/// - Parameter line: The line to use
- @inlinable
init?(line: String) {
var iterator = line.lazy.reversed().makeIterator()
guard let endChar = iterator.next() else { return nil }
diff --git a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
index 463a065c9..652274086 100644
--- a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
+++ b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
@@ -57,10 +57,7 @@ final class TextLayoutLineStorageTests: XCTestCase {
// Delete first
tree = createTree()
-// tree.printTree()
tree.delete(lineAt: 0)
-// print(tree.first)
-// tree.printTree()
XCTAssert(tree.count == 14, "Tree length incorrect")
XCTAssert(tree.first?.range.length == 2, "Failed to delete leftmost node")
From 203ffcacc841c3c30d405a0843e7223dcb051f65 Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Sun, 3 Sep 2023 13:06:13 -0500
Subject: [PATCH 33/75] Add CodeEditTextInput and Common targets
---
Package.swift | 31 ++++-
.../TextLayoutManager/LineFragment.swift | 17 ++-
.../TextLayoutManager/LineFragmentView.swift | 0
.../TextLayoutManager+Iterator.swift | 8 +-
.../TextLayoutManager/TextLayoutManager.swift | 14 ++-
.../TextLayoutManager/TextLine.swift | 8 +-
.../TextLayoutManager/Typesetter.swift | 3 +-
.../TextLineStorage+Iterator.swift | 20 ++--
.../TextLineStorage+NSTextStorage.swift | 0
.../TextLineStorage+Node.swift | 3 -
.../TextLineStorage+Structs.swift | 22 ++--
.../TextLineStorage/TextLineStorage.swift | 10 +-
.../TextSelectionManager/CursorView.swift | 6 +-
...lectionManager+SelectionManipulation.swift | 8 +-
.../TextSelectionManager.swift | 24 ++--
.../TextView/TextView+CopyPaste.swift | 0
.../TextView/TextView+Delete.swift | 0
.../TextView/TextView+Menu.swift | 0
.../TextView/TextView+NSTextInput.swift | 1 -
.../TextView/TextView+UndoRedo.swift | 2 +-
.../TextView/TextView.swift | 99 +++++++++++-----
.../Utils/CEUndoManager.swift | 1 -
.../Utils/LineEnding.swift | 8 +-
Sources/CodeEditTextView/CEScrollView.swift | 34 ------
Sources/CodeEditTextView/CETextView.swift | 36 ------
.../CodeEditTextView/CodeEditTextView.swift | 2 +-
.../STTextViewController+Lifecycle.swift | 112 +++++++++---------
...extViewController+STTextViewDelegate.swift | 60 +++++-----
.../Controller/STTextViewController.swift | 2 +-
.../CodeEditTextView/Gutter/GutterView.swift | 13 +-
.../Highlighting/Highlighter.swift | 2 +-
.../Highlighting/HighlighterTextView.swift | 8 +-
.../{TextView => }/TextViewController.swift | 2 +
.../Extensions}/NSRange+Comparable.swift | 0
.../Extensions}/NSRange+isEmpty.swift | 4 +-
.../MultiStorageDelegate.swift | 12 +-
.../Utils => Common}/ViewReuseQueue.swift | 16 +--
.../TextLayoutLineStorageTests.swift | 2 +-
38 files changed, 298 insertions(+), 292 deletions(-)
rename Sources/{CodeEditTextView/TextView => CodeEditInputView}/TextLayoutManager/LineFragment.swift (61%)
rename Sources/{CodeEditTextView/TextView => CodeEditInputView}/TextLayoutManager/LineFragmentView.swift (100%)
rename Sources/{CodeEditTextView/TextView => CodeEditInputView}/TextLayoutManager/TextLayoutManager+Iterator.swift (74%)
rename Sources/{CodeEditTextView/TextView => CodeEditInputView}/TextLayoutManager/TextLayoutManager.swift (98%)
rename Sources/{CodeEditTextView/TextView => CodeEditInputView}/TextLayoutManager/TextLine.swift (83%)
rename Sources/{CodeEditTextView/TextView => CodeEditInputView}/TextLayoutManager/Typesetter.swift (96%)
rename Sources/{CodeEditTextView/TextView => CodeEditInputView}/TextLineStorage/TextLineStorage+Iterator.swift (82%)
rename Sources/{CodeEditTextView/TextView => CodeEditInputView}/TextLineStorage/TextLineStorage+NSTextStorage.swift (100%)
rename Sources/{CodeEditTextView/TextView => CodeEditInputView}/TextLineStorage/TextLineStorage+Node.swift (98%)
rename Sources/{CodeEditTextView/TextView => CodeEditInputView}/TextLineStorage/TextLineStorage+Structs.swift (73%)
rename Sources/{CodeEditTextView/TextView => CodeEditInputView}/TextLineStorage/TextLineStorage.swift (98%)
rename Sources/{CodeEditTextView/TextView => CodeEditInputView}/TextSelectionManager/CursorView.swift (91%)
rename Sources/{CodeEditTextView/TextView => CodeEditInputView}/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift (97%)
rename Sources/{CodeEditTextView/TextView => CodeEditInputView}/TextSelectionManager/TextSelectionManager.swift (93%)
rename Sources/{CodeEditTextView/TextView => CodeEditInputView}/TextView/TextView+CopyPaste.swift (100%)
rename Sources/{CodeEditTextView/TextView => CodeEditInputView}/TextView/TextView+Delete.swift (100%)
rename Sources/{CodeEditTextView/TextView => CodeEditInputView}/TextView/TextView+Menu.swift (100%)
rename Sources/{CodeEditTextView/TextView => CodeEditInputView}/TextView/TextView+NSTextInput.swift (99%)
rename Sources/{CodeEditTextView/TextView => CodeEditInputView}/TextView/TextView+UndoRedo.swift (88%)
rename Sources/{CodeEditTextView/TextView => CodeEditInputView}/TextView/TextView.swift (82%)
rename Sources/{CodeEditTextView/TextView => CodeEditInputView}/Utils/CEUndoManager.swift (99%)
rename Sources/{CodeEditTextView/TextView => CodeEditInputView}/Utils/LineEnding.swift (89%)
delete mode 100644 Sources/CodeEditTextView/CEScrollView.swift
delete mode 100644 Sources/CodeEditTextView/CETextView.swift
rename Sources/CodeEditTextView/{TextView => }/TextViewController.swift (99%)
rename Sources/{CodeEditTextView/Extensions/NSRange+ => Common/Extensions}/NSRange+Comparable.swift (100%)
rename Sources/{CodeEditTextView/Extensions/NSRange+ => Common/Extensions}/NSRange+isEmpty.swift (68%)
rename Sources/{CodeEditTextView/TextView/Utils => Common}/MultiStorageDelegate.swift (76%)
rename Sources/{CodeEditTextView/TextView/Utils => Common}/ViewReuseQueue.swift (83%)
rename Tests/{CodeEditTextViewTests => CodeEditInputViewTests}/TextLayoutLineStorageTests.swift (99%)
diff --git a/Package.swift b/Package.swift
index 97082fe58..fcb8030e7 100644
--- a/Package.swift
+++ b/Package.swift
@@ -38,9 +38,31 @@ let package = Package(
.target(
name: "CodeEditTextView",
dependencies: [
+ "Common",
"STTextView",
+ "CodeEditInputView",
"CodeEditLanguages",
- "TextFormation",
+ "TextFormation"
+ ],
+ plugins: [
+ .plugin(name: "SwiftLint", package: "SwiftLintPlugin")
+ ]
+ ),
+
+ .target(
+ name: "CodeEditInputView",
+ dependencies: [
+ "Common",
+ "TextFormation"
+ ],
+ plugins: [
+ .plugin(name: "SwiftLint", package: "SwiftLintPlugin")
+ ]
+ ),
+
+ .target(
+ name: "Common",
+ dependencies: [
.product(name: "Collections", package: "swift-collections")
],
plugins: [
@@ -55,5 +77,12 @@ let package = Package(
"CodeEditLanguages",
]
),
+
+ .testTarget(
+ name: "CodeEditInputViewTests",
+ dependencies: [
+ "CodeEditInputView",
+ ]
+ ),
]
)
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragment.swift b/Sources/CodeEditInputView/TextLayoutManager/LineFragment.swift
similarity index 61%
rename from Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragment.swift
rename to Sources/CodeEditInputView/TextLayoutManager/LineFragment.swift
index 3bf87a73e..f2ba739db 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragment.swift
+++ b/Sources/CodeEditInputView/TextLayoutManager/LineFragment.swift
@@ -7,16 +7,15 @@
import AppKit
-final class LineFragment: Identifiable {
- let id = UUID()
- var ctLine: CTLine
- let width: CGFloat
- let height: CGFloat
- let descent: CGFloat
- let scaledHeight: CGFloat
+public final class LineFragment: Identifiable {
+ public let id = UUID()
+ private(set) public var ctLine: CTLine
+ public let width: CGFloat
+ public let height: CGFloat
+ public let descent: CGFloat
+ public let scaledHeight: CGFloat
- @inlinable
- var heightDifference: CGFloat {
+ public var heightDifference: CGFloat {
scaledHeight - height
}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentView.swift b/Sources/CodeEditInputView/TextLayoutManager/LineFragmentView.swift
similarity index 100%
rename from Sources/CodeEditTextView/TextView/TextLayoutManager/LineFragmentView.swift
rename to Sources/CodeEditInputView/TextLayoutManager/LineFragmentView.swift
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager+Iterator.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Iterator.swift
similarity index 74%
rename from Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager+Iterator.swift
rename to Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Iterator.swift
index 094dd4c90..0317194b7 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager+Iterator.swift
+++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Iterator.swift
@@ -7,8 +7,8 @@
import Foundation
-extension TextLayoutManager {
- func visibleLines() -> Iterator {
+public extension TextLayoutManager {
+ public func visibleLines() -> Iterator {
let visibleRect = delegate?.visibleRect ?? NSRect(
x: 0,
y: 0,
@@ -18,14 +18,14 @@ extension TextLayoutManager {
return Iterator(minY: max(visibleRect.minY, 0), maxY: max(visibleRect.maxY, 0), storage: self.lineStorage)
}
- struct Iterator: LazySequenceProtocol, IteratorProtocol {
+ public struct Iterator: LazySequenceProtocol, IteratorProtocol {
private var storageIterator: TextLineStorage.TextLineStorageYIterator
init(minY: CGFloat, maxY: CGFloat, storage: TextLineStorage) {
storageIterator = storage.linesStartingAt(minY, until: maxY)
}
- mutating func next() -> TextLineStorage.TextLinePosition? {
+ public mutating func next() -> TextLineStorage.TextLinePosition? {
storageIterator.next()
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift
similarity index 98%
rename from Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
rename to Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift
index e7cf4ac7a..482dbd6c4 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift
@@ -7,8 +7,9 @@
import Foundation
import AppKit
+import Common
-protocol TextLayoutManagerDelegate: AnyObject {
+public protocol TextLayoutManagerDelegate: AnyObject {
func layoutManagerHeightDidUpdate(newHeight: CGFloat)
func layoutManagerMaxWidthDidChange(newWidth: CGFloat)
func textViewSize() -> CGSize
@@ -18,8 +19,8 @@ protocol TextLayoutManagerDelegate: AnyObject {
var visibleRect: NSRect { get }
}
-final class TextLayoutManager: NSObject {
- // MARK: - Public Config
+public class TextLayoutManager: NSObject {
+ // MARK: - Public Properties
public weak var delegate: TextLayoutManagerDelegate?
public var typingAttributes: [NSAttributedString.Key: Any]
@@ -32,6 +33,11 @@ final class TextLayoutManager: NSObject {
}
}
+ /// The number of lines in the document
+ public var lineCount: Int {
+ lineStorage.count
+ }
+
// MARK: - Internal
private unowned var textStorage: NSTextStorage
@@ -378,7 +384,7 @@ extension TextLayoutManager: NSTextStorageDelegate {
/// This method is to simplify keeping the layout manager in sync with attribute changes in the storage object.
/// This does not handle cases where characters have been inserted or removed from the storage.
/// For that, see the `willPerformEdit` method.
- func textStorage(
+ public func textStorage(
_ textStorage: NSTextStorage,
didProcessEditing editedMask: NSTextStorageEditActions,
range editedRange: NSRange,
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLine.swift
similarity index 83%
rename from Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
rename to Sources/CodeEditInputView/TextLayoutManager/TextLine.swift
index fa1fa8bb0..459cc0233 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/TextLine.swift
+++ b/Sources/CodeEditInputView/TextLayoutManager/TextLine.swift
@@ -9,13 +9,17 @@ import Foundation
import AppKit
/// Represents a displayable line of text.
-final class TextLine: Identifiable {
- let id: UUID = UUID()
+public final class TextLine: Identifiable {
+ public let id: UUID = UUID()
// private weak var stringRef: NSTextStorage?
private var needsLayout: Bool = true
var maxWidth: CGFloat?
private(set) var typesetter: Typesetter = Typesetter()
+ public var lineFragments: TextLineStorage {
+ typesetter.lineFragments
+ }
+
func setNeedsLayout() {
needsLayout = true
typesetter = Typesetter()
diff --git a/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter.swift b/Sources/CodeEditInputView/TextLayoutManager/Typesetter.swift
similarity index 96%
rename from Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter.swift
rename to Sources/CodeEditInputView/TextLayoutManager/Typesetter.swift
index 731e7a18e..97d8df5b3 100644
--- a/Sources/CodeEditTextView/TextView/TextLayoutManager/Typesetter.swift
+++ b/Sources/CodeEditInputView/TextLayoutManager/Typesetter.swift
@@ -92,7 +92,8 @@ final class Typesetter {
let set = CharacterSet(
charactersIn: string.attributedSubstring(from: NSRange(location: index, length: 1)).string
)
- return set.isSubset(of: .whitespacesWithoutNewlines) || set.isSubset(of: .punctuationCharacters)
+ return set.isSubset(of: .whitespacesAndNewlines.subtracting(.newlines))
+ || set.isSubset(of: .punctuationCharacters)
}
deinit {
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Iterator.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Iterator.swift
similarity index 82%
rename from Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Iterator.swift
rename to Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Iterator.swift
index ea55e01c8..4c5a89cab 100644
--- a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Iterator.swift
+++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Iterator.swift
@@ -7,16 +7,16 @@
import Foundation
-extension TextLineStorage {
- func linesStartingAt(_ minY: CGFloat, until maxY: CGFloat) -> TextLineStorageYIterator {
+public extension TextLineStorage {
+ public func linesStartingAt(_ minY: CGFloat, until maxY: CGFloat) -> TextLineStorageYIterator {
TextLineStorageYIterator(storage: self, minY: minY, maxY: maxY)
}
- func linesInRange(_ range: NSRange) -> TextLineStorageRangeIterator {
+ public func linesInRange(_ range: NSRange) -> TextLineStorageRangeIterator {
TextLineStorageRangeIterator(storage: self, range: range)
}
- struct TextLineStorageYIterator: LazySequenceProtocol, IteratorProtocol {
+ public struct TextLineStorageYIterator: LazySequenceProtocol, IteratorProtocol {
private let storage: TextLineStorage
private let minY: CGFloat
private let maxY: CGFloat
@@ -29,7 +29,7 @@ extension TextLineStorage {
self.currentPosition = currentPosition
}
- mutating func next() -> TextLinePosition? {
+ public mutating func next() -> TextLinePosition? {
if let currentPosition {
guard currentPosition.yPos < maxY,
let nextPosition = storage.getLine(
@@ -46,7 +46,7 @@ extension TextLineStorage {
}
}
- struct TextLineStorageRangeIterator: LazySequenceProtocol, IteratorProtocol {
+ public struct TextLineStorageRangeIterator: LazySequenceProtocol, IteratorProtocol {
private let storage: TextLineStorage
private let range: NSRange
private var currentPosition: TextLinePosition?
@@ -57,7 +57,7 @@ extension TextLineStorage {
self.currentPosition = currentPosition
}
- mutating func next() -> TextLinePosition? {
+ public mutating func next() -> TextLinePosition? {
if let currentPosition {
guard currentPosition.range.max < range.max,
let nextPosition = storage.getLine(
@@ -76,11 +76,11 @@ extension TextLineStorage {
}
extension TextLineStorage: LazySequenceProtocol {
- func makeIterator() -> TextLineStorageIterator {
+ public func makeIterator() -> TextLineStorageIterator {
TextLineStorageIterator(storage: self, currentPosition: nil)
}
- struct TextLineStorageIterator: IteratorProtocol {
+ public struct TextLineStorageIterator: IteratorProtocol {
private let storage: TextLineStorage
private var currentPosition: TextLinePosition?
@@ -89,7 +89,7 @@ extension TextLineStorage: LazySequenceProtocol {
self.currentPosition = currentPosition
}
- mutating func next() -> TextLinePosition? {
+ public mutating func next() -> TextLinePosition? {
if let currentPosition {
guard currentPosition.range.max < storage.length,
let nextPosition = storage.getLine(
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+NSTextStorage.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift
similarity index 100%
rename from Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+NSTextStorage.swift
rename to Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift
similarity index 98%
rename from Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift
rename to Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift
index e06feace7..b4e3b3dc6 100644
--- a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Node.swift
+++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift
@@ -8,12 +8,10 @@
import Foundation
extension TextLineStorage {
- @inlinable
func isRightChild(_ node: Node) -> Bool {
node.parent?.right === node
}
- @inlinable
func isLeftChild(_ node: Node) -> Bool {
node.parent?.left === node
}
@@ -36,7 +34,6 @@ extension TextLineStorage {
/// - Parameters:
/// - nodeU: The node to replace.
/// - nodeV: The node to insert in place of `nodeU`
- @inlinable
func transplant(_ nodeU: Node, with nodeV: Node?) {
if nodeU.parent == nil {
root = nodeV
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Structs.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Structs.swift
similarity index 73%
rename from Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Structs.swift
rename to Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Structs.swift
index 6515d14dc..f7bf4a77d 100644
--- a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage+Structs.swift
+++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Structs.swift
@@ -8,8 +8,8 @@
import Foundation
extension TextLineStorage where Data: Identifiable {
- struct TextLinePosition {
- init(data: Data, range: NSRange, yPos: CGFloat, height: CGFloat, index: Int) {
+ public struct TextLinePosition {
+ internal init(data: Data, range: NSRange, yPos: CGFloat, height: CGFloat, index: Int) {
self.data = data
self.range = range
self.yPos = yPos
@@ -17,7 +17,7 @@ extension TextLineStorage where Data: Identifiable {
self.index = index
}
- init(position: NodePosition) {
+ internal init(position: NodePosition) {
self.data = position.node.data
self.range = NSRange(location: position.textPos, length: position.node.length)
self.yPos = position.yPos
@@ -26,15 +26,15 @@ extension TextLineStorage where Data: Identifiable {
}
/// The data stored at the position
- let data: Data
+ public let data: Data
/// The range represented by the data
- let range: NSRange
+ public let range: NSRange
/// The y position of the data, on a top down y axis
- let yPos: CGFloat
+ public let yPos: CGFloat
/// The height of the stored data
- let height: CGFloat
+ public let height: CGFloat
/// The index of the position.
- let index: Int
+ public let index: Int
}
internal struct NodePosition {
@@ -48,8 +48,8 @@ extension TextLineStorage where Data: Identifiable {
let index: Int
}
- struct BuildItem {
- let data: Data
- let length: Int
+ public struct BuildItem {
+ public let data: Data
+ public let length: Int
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift
similarity index 98%
rename from Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
rename to Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift
index 859ea70b5..3aa6dde52 100644
--- a/Sources/CodeEditTextView/TextView/TextLineStorage/TextLineStorage.swift
+++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift
@@ -8,7 +8,7 @@
import Foundation
/// Implements a red-black tree for efficiently editing, storing and retrieving lines of text in a document.
-final class TextLineStorage {
+public final class TextLineStorage {
private enum MetaFixupAction {
case inserted
case deleted
@@ -26,8 +26,7 @@ final class TextLineStorage {
public var height: CGFloat = 0
- // TODO: Cache this value & update on tree update
- var first: TextLinePosition? {
+ public var first: TextLinePosition? {
guard length > 0,
let position = search(for: 0) else {
return nil
@@ -35,13 +34,12 @@ final class TextLineStorage {
return TextLinePosition(position: position)
}
- // TODO: Cache this value & update on tree update
- var last: TextLinePosition? {
+ public var last: TextLinePosition? {
guard length > 0, let position = search(for: length - 1) else { return nil }
return TextLinePosition(position: position)
}
- init() { }
+ public init() { }
// MARK: - Public Methods
diff --git a/Sources/CodeEditTextView/TextView/TextSelectionManager/CursorView.swift b/Sources/CodeEditInputView/TextSelectionManager/CursorView.swift
similarity index 91%
rename from Sources/CodeEditTextView/TextView/TextSelectionManager/CursorView.swift
rename to Sources/CodeEditInputView/TextSelectionManager/CursorView.swift
index c3f486ecb..8011d81b1 100644
--- a/Sources/CodeEditTextView/TextView/TextSelectionManager/CursorView.swift
+++ b/Sources/CodeEditInputView/TextSelectionManager/CursorView.swift
@@ -8,14 +8,14 @@
import AppKit
/// Animates a cursor.
-class CursorView: NSView {
+open class CursorView: NSView {
private let blinkDuration: TimeInterval?
private let color: NSColor
private let width: CGFloat
private var timer: Timer?
- override var isFlipped: Bool {
+ open override var isFlipped: Bool {
true
}
@@ -46,7 +46,7 @@ class CursorView: NSView {
}
}
- required init?(coder: NSCoder) {
+ public required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
diff --git a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift
similarity index 97%
rename from Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift
rename to Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift
index 417044129..938ba41a9 100644
--- a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift
+++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift
@@ -7,7 +7,7 @@
import AppKit
-extension TextSelectionManager {
+public extension TextSelectionManager {
// MARK: - Range Of Selection
/// Creates a range for a new selection given a starting point, direction, and destination.
@@ -141,7 +141,7 @@ extension TextSelectionManager {
}
if hasFoundValidWordChar && CharacterSet
- .whitespacesWithoutNewlines
+ .whitespacesAndNewlines.subtracting(.newlines)
.union(.punctuationCharacters)
.isSuperset(of: CharacterSet(charactersIn: substring)) {
stop.pointee = true
@@ -202,7 +202,9 @@ extension TextSelectionManager {
if delta < 0 {
string.enumerateSubstrings(in: foundRange, options: .byCaretPositions) { substring, _, _, stop in
if let substring = substring as String? {
- if CharacterSet.whitespacesWithoutNewlines.isSuperset(of: CharacterSet(charactersIn: substring)) {
+ if CharacterSet
+ .whitespacesAndNewlines.subtracting(.newlines)
+ .isSuperset(of: CharacterSet(charactersIn: substring)) {
foundRange.location += 1
foundRange.length -= 1
} else {
diff --git a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift
similarity index 93%
rename from Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
rename to Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift
index 77039b93c..4e72418f7 100644
--- a/Sources/CodeEditTextView/TextView/TextSelectionManager/TextSelectionManager.swift
+++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift
@@ -6,9 +6,9 @@
//
import AppKit
-import TextStory
+import Common
-protocol TextSelectionManagerDelegate: AnyObject {
+public protocol TextSelectionManagerDelegate: AnyObject {
var font: NSFont { get }
func setNeedsDisplay()
@@ -19,7 +19,7 @@ protocol TextSelectionManagerDelegate: AnyObject {
///
/// Draws selections using a draw method similar to the `TextLayoutManager` class, and adds cursor views when
/// appropriate.
-class TextSelectionManager: NSObject {
+public class TextSelectionManager: NSObject {
struct MarkedText {
let range: NSRange
let attributedString: NSAttributedString
@@ -27,9 +27,9 @@ class TextSelectionManager: NSObject {
// MARK: - TextSelection
- class TextSelection {
- var range: NSRange
- weak var view: CursorView?
+ public class TextSelection {
+ public var range: NSRange
+ internal weak var view: CursorView?
init(range: NSRange, view: CursorView? = nil) {
self.range = range
@@ -41,7 +41,7 @@ class TextSelectionManager: NSObject {
}
}
- enum Destination {
+ public enum Destination {
case character
case word
case line
@@ -50,7 +50,7 @@ class TextSelectionManager: NSObject {
case document
}
- enum Direction {
+ public enum Direction {
case up
case down
case forward
@@ -59,14 +59,14 @@ class TextSelectionManager: NSObject {
// MARK: - Properties
- class var selectionChangedNotification: Notification.Name {
+ open class var selectionChangedNotification: Notification.Name {
Notification.Name("TextSelectionManager.TextSelectionChangedNotification")
}
public var selectedLineBackgroundColor: NSColor = NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled)
- private(set) var markedText: [MarkedText] = []
- private(set) var textSelections: [TextSelection] = []
+ private var markedText: [MarkedText] = []
+ private(set) public var textSelections: [TextSelection] = []
internal weak var layoutManager: TextLayoutManager?
internal weak var textStorage: NSTextStorage?
internal weak var layoutView: NSView?
@@ -211,7 +211,7 @@ private extension TextSelectionManager.TextSelection {
// MARK: - Text Storage Delegate
extension TextSelectionManager: NSTextStorageDelegate {
- func textStorage(
+ public func textStorage(
_ textStorage: NSTextStorage,
didProcessEditing editedMask: NSTextStorageEditActions,
range editedRange: NSRange,
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView+CopyPaste.swift b/Sources/CodeEditInputView/TextView/TextView+CopyPaste.swift
similarity index 100%
rename from Sources/CodeEditTextView/TextView/TextView/TextView+CopyPaste.swift
rename to Sources/CodeEditInputView/TextView/TextView+CopyPaste.swift
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView+Delete.swift b/Sources/CodeEditInputView/TextView/TextView+Delete.swift
similarity index 100%
rename from Sources/CodeEditTextView/TextView/TextView/TextView+Delete.swift
rename to Sources/CodeEditInputView/TextView/TextView+Delete.swift
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView+Menu.swift b/Sources/CodeEditInputView/TextView/TextView+Menu.swift
similarity index 100%
rename from Sources/CodeEditTextView/TextView/TextView/TextView+Menu.swift
rename to Sources/CodeEditInputView/TextView/TextView+Menu.swift
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift b/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift
similarity index 99%
rename from Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
rename to Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift
index 4b708214d..8fd1c239b 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView+NSTextInput.swift
+++ b/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift
@@ -6,7 +6,6 @@
//
import AppKit
-import TextStory
/**
# Marked Text Notes
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView+UndoRedo.swift b/Sources/CodeEditInputView/TextView/TextView+UndoRedo.swift
similarity index 88%
rename from Sources/CodeEditTextView/TextView/TextView/TextView+UndoRedo.swift
rename to Sources/CodeEditInputView/TextView/TextView+UndoRedo.swift
index a15892e16..d2ff83b23 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView+UndoRedo.swift
+++ b/Sources/CodeEditInputView/TextView/TextView+UndoRedo.swift
@@ -8,7 +8,7 @@
import AppKit
extension TextView {
- override var undoManager: UndoManager? {
+ public override var undoManager: UndoManager? {
_undoManager?.manager
}
diff --git a/Sources/CodeEditTextView/TextView/TextView/TextView.swift b/Sources/CodeEditInputView/TextView/TextView.swift
similarity index 82%
rename from Sources/CodeEditTextView/TextView/TextView/TextView.swift
rename to Sources/CodeEditInputView/TextView/TextView.swift
index ad940dd7b..3908909d9 100644
--- a/Sources/CodeEditTextView/TextView/TextView/TextView.swift
+++ b/Sources/CodeEditInputView/TextView/TextView.swift
@@ -6,7 +6,7 @@
//
import AppKit
-import STTextView
+import Common
import TextStory
/**
@@ -23,7 +23,7 @@ import TextStory
| |-> [TextSelection]
```
*/
-class TextView: NSView, NSTextContent {
+public class TextView: NSView, NSTextContent {
// MARK: - Configuration
func setString(_ string: String) {
@@ -46,11 +46,36 @@ class TextView: NSView, NSTextContent {
open var contentType: NSTextContentType?
- // MARK: - Internal Properties
+ public var textStorage: NSTextStorage! {
+ didSet {
+ setUpLayoutManager()
+ setUpSelectionManager()
+ needsDisplay = true
+ needsLayout = true
+ }
+ }
+ public var layoutManager: TextLayoutManager! {
+ willSet {
+ if let oldValue = layoutManager {
+ storageDelegate.removeDelegate(oldValue)
+ }
+ if let newValue {
+ storageDelegate.addDelegate(newValue)
+ }
+ }
+ }
+ public var selectionManager: TextSelectionManager! {
+ willSet {
+ if let oldValue = selectionManager {
+ storageDelegate.removeDelegate(oldValue)
+ }
+ if let newValue {
+ storageDelegate.addDelegate(newValue)
+ }
+ }
+ }
- private(set) var textStorage: NSTextStorage!
- private(set) var layoutManager: TextLayoutManager!
- private(set) var selectionManager: TextSelectionManager!
+ // MARK: - Private Properties
internal var isFirstResponder: Bool = false
@@ -62,9 +87,11 @@ class TextView: NSView, NSTextContent {
return enclosingScrollView
}
+ private weak var storageDelegate: MultiStorageDelegate!
+
// MARK: - Init
- init(
+ public init(
string: String,
font: NSFont,
lineHeight: CGFloat,
@@ -72,9 +99,10 @@ class TextView: NSView, NSTextContent {
editorOverscroll: CGFloat,
isEditable: Bool,
letterSpacing: Double,
- storageDelegate: MultiStorageDelegate!
+ storageDelegate: MultiStorageDelegate
) {
self.textStorage = NSTextStorage(string: string)
+ self.storageDelegate = storageDelegate
self.font = font
self.lineHeight = lineHeight
@@ -93,7 +121,21 @@ class TextView: NSView, NSTextContent {
// TODO: Implement typing/default attributes
textStorage.addAttributes([.font: font], range: documentRange)
+ textStorage.delegate = storageDelegate
+
+ setUpLayoutManager()
+ setUpSelectionManager()
+ _undoManager = CEUndoManager(textView: self)
+
+ layoutManager.layoutLines()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ private func setUpLayoutManager() {
layoutManager = TextLayoutManager(
textStorage: textStorage,
typingAttributes: [
@@ -104,24 +146,19 @@ class TextView: NSView, NSTextContent {
textView: self, // TODO: This is an odd syntax... consider reworking this
delegate: self
)
- textStorage.delegate = storageDelegate
- storageDelegate.addDelegate(layoutManager)
+ }
+ private func setUpSelectionManager() {
selectionManager = TextSelectionManager(
layoutManager: layoutManager,
textStorage: textStorage,
layoutView: self, // TODO: This is an odd syntax... consider reworking this
delegate: self
)
- storageDelegate.addDelegate(selectionManager)
-
- _undoManager = CEUndoManager(textView: self)
-
- layoutManager.layoutLines()
}
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
+ public var documentRange: NSRange {
+ NSRange(location: 0, length: textStorage.length)
}
// MARK: - First Responder
@@ -162,19 +199,19 @@ class TextView: NSView, NSTextContent {
// MARK: - View Lifecycle
- override func viewWillMove(toWindow newWindow: NSWindow?) {
+ public override func viewWillMove(toWindow newWindow: NSWindow?) {
super.viewWillMove(toWindow: newWindow)
layoutManager.layoutLines()
}
- override func viewDidEndLiveResize() {
+ public override func viewDidEndLiveResize() {
super.viewDidEndLiveResize()
updateFrameIfNeeded()
}
// MARK: - Interaction
- override func keyDown(with event: NSEvent) {
+ public override func keyDown(with event: NSEvent) {
guard isEditable else {
super.keyDown(with: event)
return
@@ -189,7 +226,7 @@ class TextView: NSView, NSTextContent {
}
}
- override func mouseDown(with event: NSEvent) {
+ public override func mouseDown(with event: NSEvent) {
// Set cursor
guard let offset = layoutManager.textOffsetAtPoint(self.convert(event.locationInWindow, from: nil)) else {
super.mouseDown(with: event)
@@ -232,7 +269,7 @@ class TextView: NSView, NSTextContent {
// MARK: - Layout
- override func draw(_ dirtyRect: NSRect) {
+ public override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
if isSelectable {
selectionManager.drawSelections(in: dirtyRect)
@@ -243,7 +280,7 @@ class TextView: NSView, NSTextContent {
true
}
- override var visibleRect: NSRect {
+ public override var visibleRect: NSRect {
if let scrollView = scrollView {
var rect = scrollView.documentVisibleRect
rect.origin.y += scrollView.contentInsets.top
@@ -254,7 +291,7 @@ class TextView: NSView, NSTextContent {
}
}
- var visibleTextRange: NSRange? {
+ public var visibleTextRange: NSRange? {
let minY = max(visibleRect.minY, 0)
let maxY = min(visibleRect.maxY, layoutManager.estimatedHeight())
guard let minYLine = layoutManager.textLineForPosition(minY),
@@ -320,15 +357,15 @@ class TextView: NSView, NSTextContent {
// MARK: - TextLayoutManagerDelegate
extension TextView: TextLayoutManagerDelegate {
- func layoutManagerHeightDidUpdate(newHeight: CGFloat) {
+ public func layoutManagerHeightDidUpdate(newHeight: CGFloat) {
updateFrameIfNeeded()
}
- func layoutManagerMaxWidthDidChange(newWidth: CGFloat) {
+ public func layoutManagerMaxWidthDidChange(newWidth: CGFloat) {
updateFrameIfNeeded()
}
- func textViewSize() -> CGSize {
+ public func textViewSize() -> CGSize {
if let scrollView = scrollView {
var size = scrollView.contentSize
size.height -= scrollView.contentInsets.top + scrollView.contentInsets.bottom
@@ -338,12 +375,12 @@ extension TextView: TextLayoutManagerDelegate {
}
}
- func textLayoutSetNeedsDisplay() {
+ public func textLayoutSetNeedsDisplay() {
needsDisplay = true
needsLayout = true
}
- func layoutManagerYAdjustment(_ yAdjustment: CGFloat) {
+ public func layoutManagerYAdjustment(_ yAdjustment: CGFloat) {
var point = scrollView?.documentVisibleRect.origin ?? .zero
point.y += yAdjustment
scrollView?.documentView?.scroll(point)
@@ -353,11 +390,11 @@ extension TextView: TextLayoutManagerDelegate {
// MARK: - TextSelectionManagerDelegate
extension TextView: TextSelectionManagerDelegate {
- func setNeedsDisplay() {
+ public func setNeedsDisplay() {
self.setNeedsDisplay(visibleRect)
}
- func estimatedLineHeight() -> CGFloat {
+ public func estimatedLineHeight() -> CGFloat {
layoutManager.estimateLineHeight()
}
}
diff --git a/Sources/CodeEditTextView/TextView/Utils/CEUndoManager.swift b/Sources/CodeEditInputView/Utils/CEUndoManager.swift
similarity index 99%
rename from Sources/CodeEditTextView/TextView/Utils/CEUndoManager.swift
rename to Sources/CodeEditInputView/Utils/CEUndoManager.swift
index 6bc6af50e..794d7fbfd 100644
--- a/Sources/CodeEditTextView/TextView/Utils/CEUndoManager.swift
+++ b/Sources/CodeEditInputView/Utils/CEUndoManager.swift
@@ -5,7 +5,6 @@
// Created by Khan Winter on 7/8/23.
//
-import STTextView
import AppKit
import TextStory
diff --git a/Sources/CodeEditTextView/TextView/Utils/LineEnding.swift b/Sources/CodeEditInputView/Utils/LineEnding.swift
similarity index 89%
rename from Sources/CodeEditTextView/TextView/Utils/LineEnding.swift
rename to Sources/CodeEditInputView/Utils/LineEnding.swift
index 0d9e42f2f..6bd98b9a9 100644
--- a/Sources/CodeEditTextView/TextView/Utils/LineEnding.swift
+++ b/Sources/CodeEditInputView/Utils/LineEnding.swift
@@ -7,7 +7,7 @@
import AppKit
-enum LineEnding: String {
+public enum LineEnding: String {
/// The default unix `\n` character
case lf = "\n"
/// MacOS line ending `\r` character
@@ -17,7 +17,7 @@ enum LineEnding: String {
/// Initialize a line ending from a line string.
/// - Parameter line: The line to use
- init?(line: String) {
+ public init?(line: String) {
var iterator = line.lazy.reversed().makeIterator()
guard let endChar = iterator.next() else { return nil }
if endChar == "\n" {
@@ -36,7 +36,7 @@ enum LineEnding: String {
/// Attempts to detect the line ending from a line storage.
/// - Parameter lineStorage: The line storage to enumerate.
/// - Returns: A line ending. Defaults to `.lf` if none could be found.
- static func detectLineEnding(lineStorage: TextLineStorage, textStorage: NSTextStorage) -> LineEnding {
+ public static func detectLineEnding(lineStorage: TextLineStorage, textStorage: NSTextStorage) -> LineEnding {
var histogram: [LineEnding: Int] = [
.lf: 0,
.cr: 0,
@@ -60,7 +60,7 @@ enum LineEnding: String {
return histogram.max(by: { $0.value < $1.value })?.key ?? .lf
}
- var length: Int {
+ public var length: Int {
rawValue.count
}
}
diff --git a/Sources/CodeEditTextView/CEScrollView.swift b/Sources/CodeEditTextView/CEScrollView.swift
deleted file mode 100644
index c16514663..000000000
--- a/Sources/CodeEditTextView/CEScrollView.swift
+++ /dev/null
@@ -1,34 +0,0 @@
-//
-// CEScrollView.swift
-//
-//
-// Created by Renan Greca on 18/02/23.
-//
-
-import AppKit
-import STTextView
-
-class CEScrollView: NSScrollView {
-
- override open var contentSize: NSSize {
- var proposedSize = super.contentSize
- proposedSize.width -= verticalRulerView?.requiredThickness ?? 0.0
- return proposedSize
- }
-
- override func mouseDown(with event: NSEvent) {
-
- if let textView = self.documentView as? STTextView,
- !textView.visibleRect.contains(event.locationInWindow) {
- // If the `scrollView` was clicked, but the click did not happen within the `textView`,
- // set cursor to the last index of the `textView`.
-
- let endLocation = textView.textLayoutManager.documentRange.endLocation
- let range = NSTextRange(location: endLocation)
- _ = textView.becomeFirstResponder()
- textView.setSelectedTextRange(range)
- }
-
- super.mouseDown(with: event)
- }
-}
diff --git a/Sources/CodeEditTextView/CETextView.swift b/Sources/CodeEditTextView/CETextView.swift
deleted file mode 100644
index dfb363b9a..000000000
--- a/Sources/CodeEditTextView/CETextView.swift
+++ /dev/null
@@ -1,36 +0,0 @@
-//
-// CETextView.swift
-// CodeEditTextView
-//
-// Created by Khan Winter on 7/8/23.
-//
-
-import AppKit
-import UniformTypeIdentifiers
-import TextStory
-import STTextView
-
-class CETextView: STTextView {
- override open func paste(_ sender: Any?) {
- guard let undoManager = undoManager as? CEUndoManager.DelegatedUndoManager else { return }
- undoManager.parent?.beginGrouping()
-
- let pasteboard = NSPasteboard.general
- if pasteboard.canReadItem(withDataConformingToTypes: [UTType.text.identifier]),
- let string = NSPasteboard.general.string(forType: .string) {
- for textRange in textLayoutManager
- .textSelections
- .flatMap(\.textRanges)
- .sorted(by: { $0.location.compare($1.location) == .orderedDescending }) {
- if let nsRange = textRange.nsRange(using: textContentManager) {
- undoManager.registerMutation(
- TextMutation(insert: string, at: nsRange.location, limit: textContentStorage?.length ?? 0)
- )
- }
- replaceCharacters(in: textRange, with: string)
- }
- }
-
- undoManager.parent?.endGrouping()
- }
-}
diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift
index 8ec17fc9a..a906f7f87 100644
--- a/Sources/CodeEditTextView/CodeEditTextView.swift
+++ b/Sources/CodeEditTextView/CodeEditTextView.swift
@@ -6,7 +6,7 @@
//
import SwiftUI
-import STTextView
+import CodeEditInputView
import CodeEditLanguages
/// A `SwiftUI` wrapper for a ``STTextViewController``.
diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift b/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift
index 39baa7b71..9463d488b 100644
--- a/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift
+++ b/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift
@@ -9,62 +9,62 @@ import AppKit
import STTextView
extension STTextViewController {
- public override func loadView() {
- textView = CETextView()
-
- let scrollView = CEScrollView()
- scrollView.translatesAutoresizingMaskIntoConstraints = false
- scrollView.hasVerticalScroller = true
- scrollView.documentView = textView
- scrollView.automaticallyAdjustsContentInsets = contentInsets == nil
-
- rulerView = STLineNumberRulerView(textView: textView, scrollView: scrollView)
- rulerView.drawSeparator = false
- rulerView.baselineOffset = baselineOffset
- rulerView.allowsMarkers = false
- rulerView.backgroundColor = theme.background
- rulerView.textColor = .secondaryLabelColor
-
- scrollView.verticalRulerView = rulerView
- scrollView.rulersVisible = true
-
- textView.typingAttributes = attributesFor(nil)
- textView.typingAttributes[.paragraphStyle] = self.paragraphStyle
- textView.font = self.font
- textView.insertionPointWidth = 1.0
- textView.backgroundColor = .clear
-
- textView.string = self.text.wrappedValue
- textView.allowsUndo = true
- textView.setupMenus()
- textView.delegate = self
-
- scrollView.documentView = textView
- scrollView.translatesAutoresizingMaskIntoConstraints = false
- scrollView.backgroundColor = useThemeBackground ? theme.background : .clear
-
- self.view = scrollView
-
- NSLayoutConstraint.activate([
- scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- scrollView.topAnchor.constraint(equalTo: view.topAnchor),
- scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
- ])
-
- NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
- self.keyDown(with: event)
- return event
- }
-
-// textViewUndoManager = CEUndoManager(textView: textView)
- reloadUI()
- setUpHighlighter()
- setHighlightProvider(self.highlightProvider)
- setUpTextFormation()
-
- self.setCursorPosition(self.cursorPosition.wrappedValue)
- }
+// public override func loadView() {
+// textView = CETextView()
+//
+// let scrollView = CEScrollView()
+// scrollView.translatesAutoresizingMaskIntoConstraints = false
+// scrollView.hasVerticalScroller = true
+// scrollView.documentView = textView
+// scrollView.automaticallyAdjustsContentInsets = contentInsets == nil
+//
+// rulerView = STLineNumberRulerView(textView: textView, scrollView: scrollView)
+// rulerView.drawSeparator = false
+// rulerView.baselineOffset = baselineOffset
+// rulerView.allowsMarkers = false
+// rulerView.backgroundColor = theme.background
+// rulerView.textColor = .secondaryLabelColor
+//
+// scrollView.verticalRulerView = rulerView
+// scrollView.rulersVisible = true
+//
+// textView.typingAttributes = attributesFor(nil)
+// textView.typingAttributes[.paragraphStyle] = self.paragraphStyle
+// textView.font = self.font
+// textView.insertionPointWidth = 1.0
+// textView.backgroundColor = .clear
+//
+// textView.string = self.text.wrappedValue
+// textView.allowsUndo = true
+// textView.setupMenus()
+// textView.delegate = self
+//
+// scrollView.documentView = textView
+// scrollView.translatesAutoresizingMaskIntoConstraints = false
+// scrollView.backgroundColor = useThemeBackground ? theme.background : .clear
+//
+// self.view = scrollView
+//
+// NSLayoutConstraint.activate([
+// scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+// scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+// scrollView.topAnchor.constraint(equalTo: view.topAnchor),
+// scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
+// ])
+//
+// NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
+// self.keyDown(with: event)
+// return event
+// }
+//
+//// textViewUndoManager = CEUndoManager(textView: textView)
+// reloadUI()
+// setUpHighlighter()
+// setHighlightProvider(self.highlightProvider)
+// setUpTextFormation()
+//
+// self.setCursorPosition(self.cursorPosition.wrappedValue)
+// }
public override func viewDidLoad() {
super.viewDidLoad()
diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+STTextViewDelegate.swift b/Sources/CodeEditTextView/Controller/STTextViewController+STTextViewDelegate.swift
index 390353aa9..2a0b41ec3 100644
--- a/Sources/CodeEditTextView/Controller/STTextViewController+STTextViewDelegate.swift
+++ b/Sources/CodeEditTextView/Controller/STTextViewController+STTextViewDelegate.swift
@@ -10,34 +10,34 @@ import STTextView
import TextStory
extension STTextViewController {
- public func undoManager(for textView: STTextView) -> UndoManager? {
- textViewUndoManager.manager
- }
-
- public func textView(
- _ textView: STTextView,
- shouldChangeTextIn affectedCharRange: NSTextRange,
- replacementString: String?
- ) -> Bool {
- guard let textContentStorage = textView.textContentStorage,
- let range = affectedCharRange.nsRange(using: textContentStorage),
- !textViewUndoManager.isUndoing,
- !textViewUndoManager.isRedoing else {
- return true
- }
-
- let mutation = TextMutation(
- string: replacementString ?? "",
- range: range,
- limit: textView.textContentStorage?.length ?? 0
- )
-
- let result = shouldApplyMutation(mutation, to: textView)
-
- if result {
- textViewUndoManager.registerMutation(mutation)
- }
-
- return result
- }
+// public func undoManager(for textView: STTextView) -> UndoManager? {
+// textViewUndoManager.manager
+// }
+//
+// public func textView(
+// _ textView: STTextView,
+// shouldChangeTextIn affectedCharRange: NSTextRange,
+// replacementString: String?
+// ) -> Bool {
+// guard let textContentStorage = textView.textContentStorage,
+// let range = affectedCharRange.nsRange(using: textContentStorage),
+// !textViewUndoManager.isUndoing,
+// !textViewUndoManager.isRedoing else {
+// return true
+// }
+//
+// let mutation = TextMutation(
+// string: replacementString ?? "",
+// range: range,
+// limit: textView.textContentStorage?.length ?? 0
+// )
+//
+// let result = shouldApplyMutation(mutation, to: textView)
+//
+// if result {
+// textViewUndoManager.registerMutation(mutation)
+// }
+//
+// return result
+// }
}
diff --git a/Sources/CodeEditTextView/Controller/STTextViewController.swift b/Sources/CodeEditTextView/Controller/STTextViewController.swift
index f320f0ea5..4c0ac129b 100644
--- a/Sources/CodeEditTextView/Controller/STTextViewController.swift
+++ b/Sources/CodeEditTextView/Controller/STTextViewController.swift
@@ -27,7 +27,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt
/// for every new selection.
internal var lastTextSelections: [NSTextRange] = []
- internal var textViewUndoManager: CEUndoManager!
+// internal var textViewUndoManager: CEUndoManager!
/// Binding for the `textView`s string
public var text: Binding
diff --git a/Sources/CodeEditTextView/Gutter/GutterView.swift b/Sources/CodeEditTextView/Gutter/GutterView.swift
index 88c824810..2e3ece3f5 100644
--- a/Sources/CodeEditTextView/Gutter/GutterView.swift
+++ b/Sources/CodeEditTextView/Gutter/GutterView.swift
@@ -6,8 +6,9 @@
//
import AppKit
+import CodeEditInputView
-class GutterView: NSView {
+public class GutterView: NSView {
struct EdgeInsets: Equatable, Hashable {
let leading: CGFloat
let trailing: CGFloat
@@ -40,11 +41,11 @@ class GutterView: NSView {
/// The maximum number of digits found for a line number.
private var maxLineLength: Int = 0
- override var isFlipped: Bool {
+ override public var isFlipped: Bool {
true
}
- init(
+ public init(
font: NSFont,
textColor: NSColor,
textView: TextView
@@ -85,7 +86,7 @@ class GutterView: NSView {
]
let originalMaxWidth = maxWidth
// Reserve at least 3 digits of space no matter what
- let lineStorageDigits = max(3, String(textView.layoutManager.lineStorage.count).count)
+ let lineStorageDigits = max(3, String(textView.layoutManager.lineCount).count)
if maxLineLength < lineStorageDigits {
// Update the max width
@@ -142,7 +143,7 @@ class GutterView: NSView {
let ctLine = CTLineCreateWithAttributedString(
NSAttributedString(string: "\(linePosition.index + 1)", attributes: attributes)
)
- let fragment: LineFragment? = linePosition.data.typesetter.lineFragments.first?.data
+ let fragment: LineFragment? = linePosition.data.lineFragments.first?.data
var ascent: CGFloat = 0
let lineNumberWidth = CTLineGetTypographicBounds(ctLine, &ascent, nil, nil)
@@ -159,7 +160,7 @@ class GutterView: NSView {
context.restoreGState()
}
- override func draw(_ dirtyRect: NSRect) {
+ public override func draw(_ dirtyRect: NSRect) {
guard let context = NSGraphicsContext.current?.cgContext else {
return
}
diff --git a/Sources/CodeEditTextView/Highlighting/Highlighter.swift b/Sources/CodeEditTextView/Highlighting/Highlighter.swift
index 22a860845..fafe0d69b 100644
--- a/Sources/CodeEditTextView/Highlighting/Highlighter.swift
+++ b/Sources/CodeEditTextView/Highlighting/Highlighter.swift
@@ -7,7 +7,7 @@
import Foundation
import AppKit
-import STTextView
+import CodeEditInputView
import SwiftTreeSitter
import CodeEditLanguages
diff --git a/Sources/CodeEditTextView/Highlighting/HighlighterTextView.swift b/Sources/CodeEditTextView/Highlighting/HighlighterTextView.swift
index b44cef16c..44ce505d3 100644
--- a/Sources/CodeEditTextView/Highlighting/HighlighterTextView.swift
+++ b/Sources/CodeEditTextView/Highlighting/HighlighterTextView.swift
@@ -7,7 +7,7 @@
import Foundation
import AppKit
-import STTextView
+import CodeEditInputView
/// The object `HighlightProviding` objects are given when asked for highlights.
public protocol HighlighterTextView: AnyObject {
@@ -18,11 +18,7 @@ public protocol HighlighterTextView: AnyObject {
}
extension TextView: HighlighterTextView {
- var documentRange: NSRange {
- NSRange(location: 0, length: textStorage.length)
- }
-
- func stringForRange(_ nsRange: NSRange) -> String? {
+ public func stringForRange(_ nsRange: NSRange) -> String? {
textStorage.substring(from: nsRange)
}
}
diff --git a/Sources/CodeEditTextView/TextView/TextViewController.swift b/Sources/CodeEditTextView/TextViewController.swift
similarity index 99%
rename from Sources/CodeEditTextView/TextView/TextViewController.swift
rename to Sources/CodeEditTextView/TextViewController.swift
index d254c6409..0792679e3 100644
--- a/Sources/CodeEditTextView/TextView/TextViewController.swift
+++ b/Sources/CodeEditTextView/TextViewController.swift
@@ -6,9 +6,11 @@
//
import AppKit
+import CodeEditInputView
import CodeEditLanguages
import SwiftUI
import SwiftTreeSitter
+import Common
public class TextViewController: NSViewController {
var scrollView: NSScrollView!
diff --git a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+Comparable.swift b/Sources/Common/Extensions/NSRange+Comparable.swift
similarity index 100%
rename from Sources/CodeEditTextView/Extensions/NSRange+/NSRange+Comparable.swift
rename to Sources/Common/Extensions/NSRange+Comparable.swift
diff --git a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+isEmpty.swift b/Sources/Common/Extensions/NSRange+isEmpty.swift
similarity index 68%
rename from Sources/CodeEditTextView/Extensions/NSRange+/NSRange+isEmpty.swift
rename to Sources/Common/Extensions/NSRange+isEmpty.swift
index 36af2cb54..05c0c9496 100644
--- a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+isEmpty.swift
+++ b/Sources/Common/Extensions/NSRange+isEmpty.swift
@@ -7,8 +7,8 @@
import Foundation
-extension NSRange {
- var isEmpty: Bool {
+public extension NSRange {
+ public var isEmpty: Bool {
length == 0
}
}
diff --git a/Sources/CodeEditTextView/TextView/Utils/MultiStorageDelegate.swift b/Sources/Common/MultiStorageDelegate.swift
similarity index 76%
rename from Sources/CodeEditTextView/TextView/Utils/MultiStorageDelegate.swift
rename to Sources/Common/MultiStorageDelegate.swift
index ab737c650..cf4bcf0ea 100644
--- a/Sources/CodeEditTextView/TextView/Utils/MultiStorageDelegate.swift
+++ b/Sources/Common/MultiStorageDelegate.swift
@@ -7,14 +7,18 @@
import AppKit
-class MultiStorageDelegate: NSObject, NSTextStorageDelegate {
+public class MultiStorageDelegate: NSObject, NSTextStorageDelegate {
private var delegates = NSHashTable.weakObjects()
- func addDelegate(_ delegate: NSTextStorageDelegate) {
+ public func addDelegate(_ delegate: NSTextStorageDelegate) {
delegates.add(delegate)
}
- func textStorage(
+ public func removeDelegate(_ delegate: NSTextStorageDelegate) {
+ delegates.remove(delegate)
+ }
+
+ public func textStorage(
_ textStorage: NSTextStorage,
didProcessEditing editedMask: NSTextStorageEditActions,
range editedRange: NSRange,
@@ -25,7 +29,7 @@ class MultiStorageDelegate: NSObject, NSTextStorageDelegate {
}
}
- func textStorage(
+ public func textStorage(
_ textStorage: NSTextStorage,
willProcessEditing editedMask: NSTextStorageEditActions,
range editedRange: NSRange,
diff --git a/Sources/CodeEditTextView/TextView/Utils/ViewReuseQueue.swift b/Sources/Common/ViewReuseQueue.swift
similarity index 83%
rename from Sources/CodeEditTextView/TextView/Utils/ViewReuseQueue.swift
rename to Sources/Common/ViewReuseQueue.swift
index 6f2a50dcb..3a044d5ee 100644
--- a/Sources/CodeEditTextView/TextView/Utils/ViewReuseQueue.swift
+++ b/Sources/Common/ViewReuseQueue.swift
@@ -9,12 +9,14 @@ import AppKit
import DequeModule
/// Maintains a queue of views available for reuse.
-class ViewReuseQueue {
+public class ViewReuseQueue {
/// A stack of views that are not currently in use
- var queuedViews: Deque = []
+ public var queuedViews: Deque = []
/// Maps views that are no longer queued to the keys they're queued with.
- var usedViews: [Key: View] = [:]
+ public var usedViews: [Key: View] = [:]
+
+ public init() { }
/// Finds, dequeues, or creates a view for the given key.
///
@@ -24,7 +26,7 @@ class ViewReuseQueue {
///
/// - Parameter key: The key for the view to find.
/// - Returns: A view for the given key.
- func getOrCreateView(forKey key: Key) -> View {
+ public func getOrCreateView(forKey key: Key) -> View {
let view: View
if let usedView = usedViews[key] {
view = usedView
@@ -38,7 +40,7 @@ class ViewReuseQueue {
/// Removes a view for the given key and enqueues it for reuse.
/// - Parameter key: The key for the view to reuse.
- func enqueueView(forKey key: Key) {
+ public func enqueueView(forKey key: Key) {
guard let view = usedViews[key] else { return }
if queuedViews.count < usedViews.count / 4 {
queuedViews.append(view)
@@ -49,7 +51,7 @@ class ViewReuseQueue {
/// Enqueues all views not in the given set.
/// - Parameter outsideSet: The keys who's views should not be enqueued for reuse.
- func enqueueViews(notInSet keys: Set) {
+ public func enqueueViews(notInSet keys: Set) {
// Get all keys that are in "use" but not in the given set.
for key in Set(usedViews.keys).subtracting(keys) {
enqueueView(forKey: key)
@@ -58,7 +60,7 @@ class ViewReuseQueue {
/// Enqueues all views keyed by the given set.
/// - Parameter keys: The keys for all the views that should be enqueued.
- func enqueueViews(in keys: Set) {
+ public func enqueueViews(in keys: Set) {
for key in keys {
enqueueView(forKey: key)
}
diff --git a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift b/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift
similarity index 99%
rename from Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
rename to Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift
index 652274086..bfa61ee47 100644
--- a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift
+++ b/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift
@@ -1,5 +1,5 @@
import XCTest
-@testable import CodeEditTextView
+@testable import CodeEditInputView
final class TextLayoutLineStorageTests: XCTestCase {
func test_insert() {
From 314102697a17fa7067536a585510c26694f68299 Mon Sep 17 00:00:00 2001
From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
Date: Sun, 3 Sep 2023 14:39:21 -0500
Subject: [PATCH 34/75] Finish delete
---
.../TextLayoutManager+Edits.swift | 81 +++++++++
.../TextLayoutManager/TextLayoutManager.swift | 39 +----
.../LineFragment.swift | 0
.../LineFragmentView.swift | 0
.../TextLine.swift | 0
.../Typesetter.swift | 0
.../TextLineStorage+NSTextStorage.swift | 15 +-
.../TextLineStorage/TextLineStorage.swift | 17 +-
...lectionManager+SelectionManipulation.swift | 4 +-
.../TextView/TextView+ReplaceCharacters.swift | 47 ++++++
.../CodeEditInputView/TextView/TextView.swift | 30 +---
.../TextView/TextViewDelegate.swift | 20 +++
.../NSRange+}/NSRange+Comparable.swift | 0
.../CodeEditTextView/TextViewController.swift | 7 +
.../Extensions/NSTextStorage+getLine.swift | 28 ++++
.../TextLayoutLineStorageTests.swift | 154 +++++++++++++++++-
16 files changed, 356 insertions(+), 86 deletions(-)
create mode 100644 Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift
rename Sources/CodeEditInputView/{TextLayoutManager => TextLine}/LineFragment.swift (100%)
rename Sources/CodeEditInputView/{TextLayoutManager => TextLine}/LineFragmentView.swift (100%)
rename Sources/CodeEditInputView/{TextLayoutManager => TextLine}/TextLine.swift (100%)
rename Sources/CodeEditInputView/{TextLayoutManager => TextLine}/Typesetter.swift (100%)
create mode 100644 Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift
create mode 100644 Sources/CodeEditInputView/TextView/TextViewDelegate.swift
rename Sources/{Common/Extensions => CodeEditTextView/Extensions/NSRange+}/NSRange+Comparable.swift (100%)
create mode 100644 Sources/Common/Extensions/NSTextStorage+getLine.swift
diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift
new file mode 100644
index 000000000..fa63c75cc
--- /dev/null
+++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift
@@ -0,0 +1,81 @@
+//
+// TextLayoutManager+Edits.swift
+//
+//
+// Created by Khan Winter on 9/3/23.
+//
+
+import AppKit
+import Common
+
+// MARK: - Edits
+
+extension TextLayoutManager: NSTextStorageDelegate {
+ /// Notifies the layout manager of an edit.
+ ///
+ /// Used by the `TextView` to tell the layout manager about any edits that will happen.
+ /// Use this to keep the layout manager's line storage in sync with the text storage.
+ ///
+ /// - Parameters:
+ /// - range: The range of the edit.
+ /// - string: The string to replace in the given range.
+ public func willReplaceCharactersInRange(range: NSRange, with string: String) {
+ // Loop through each line being replaced in reverse, updating and removing where necessary.
+ for linePosition in lineStorage.linesInRange(range).reversed() {
+ // Two cases: Updated line, deleted line entirely
+ guard let intersection = linePosition.range.intersection(range), !intersection.isEmpty else { continue }
+ if intersection == linePosition.range {
+ // Delete line
+ lineStorage.delete(lineAt: linePosition.range.location)
+ } else if intersection.max == linePosition.range.max,
+ let nextLine = lineStorage.getLine(atIndex: linePosition.range.max) {
+ // Need to merge line with one after it after updating this line to remove the end of the line
+ lineStorage.delete(lineAt: nextLine.range.location)
+ let delta = -intersection.length + nextLine.range.length
+ if delta != 0 {
+ lineStorage.update(atIndex: linePosition.range.location, delta: delta, deltaHeight: 0)
+ }
+ } else {
+ lineStorage.update(atIndex: linePosition.range.location, delta: -intersection.length, deltaHeight: 0)
+ }
+ }
+
+ // Loop through each line being inserted, inserting where necessary
+ if !string.isEmpty {
+ print("Inserting...")
+ print(lineStorage.getLine(atIndex: range.location)!.range)
+ var index = 0
+ while let nextLine = (string as NSString).getNextLine(startingAt: index) {
+ print(nextLine)
+ index = nextLine.max
+ }
+
+ if index < string.lengthOfBytes(using: .utf16) {
+ // Get the last line.
+ let lastLine = (string as NSString).substring(from: index)
+ print("Last line", lastLine, range.location + index)
+ lineStorage.update(
+ atIndex: range.location + index,
+ delta: lastLine.lengthOfBytes(using: .utf16),
+ deltaHeight: 0.0
+ )
+ }
+ }
+
+ setNeedsLayout()
+ }
+
+ /// This method is to simplify keeping the layout manager in sync with attribute changes in the storage object.
+ /// This does not handle cases where characters have been inserted or removed from the storage.
+ /// For that, see the `willPerformEdit` method.
+ public func textStorage(
+ _ textStorage: NSTextStorage,
+ didProcessEditing editedMask: NSTextStorageEditActions,
+ range editedRange: NSRange,
+ changeInLength delta: Int
+ ) {
+ if editedMask.contains(.editedAttributes) && delta == 0 {
+ invalidateLayoutForRange(editedRange)
+ }
+ }
+}
diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift
index 482dbd6c4..a87d4795f 100644
--- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift
+++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift
@@ -40,7 +40,7 @@ public class TextLayoutManager: NSObject {
// MARK: - Internal
- private unowned var textStorage: NSTextStorage
+ internal unowned var textStorage: NSTextStorage
internal var lineStorage: TextLineStorage = TextLineStorage()
private let viewReuseQueue: ViewReuseQueue