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 = ViewReuseQueue() private var visibleLineIds: Set = [] @@ -358,40 +358,3 @@ public class TextLayoutManager: NSObject { delegate = nil } } - -// 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. - 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/LineFragment.swift b/Sources/CodeEditInputView/TextLine/LineFragment.swift similarity index 100% rename from Sources/CodeEditInputView/TextLayoutManager/LineFragment.swift rename to Sources/CodeEditInputView/TextLine/LineFragment.swift diff --git a/Sources/CodeEditInputView/TextLayoutManager/LineFragmentView.swift b/Sources/CodeEditInputView/TextLine/LineFragmentView.swift similarity index 100% rename from Sources/CodeEditInputView/TextLayoutManager/LineFragmentView.swift rename to Sources/CodeEditInputView/TextLine/LineFragmentView.swift diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLine.swift b/Sources/CodeEditInputView/TextLine/TextLine.swift similarity index 100% rename from Sources/CodeEditInputView/TextLayoutManager/TextLine.swift rename to Sources/CodeEditInputView/TextLine/TextLine.swift diff --git a/Sources/CodeEditInputView/TextLayoutManager/Typesetter.swift b/Sources/CodeEditInputView/TextLine/Typesetter.swift similarity index 100% rename from Sources/CodeEditInputView/TextLayoutManager/Typesetter.swift rename to Sources/CodeEditInputView/TextLine/Typesetter.swift diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift index 3a29b14b8..b42b78dc0 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift @@ -6,6 +6,7 @@ // import AppKit +import Common extension TextLineStorage where Data == TextLine { /// Builds the line storage object from the given `NSTextStorage`. @@ -13,21 +14,9 @@ extension TextLineStorage where Data == TextLine { /// - 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: [BuildItem] = [] - while let range = getNextLine(startingAt: index) { + while let range = textStorage.getNextLine(startingAt: index) { lines.append( BuildItem( data: TextLine(), diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift index 3aa6dde52..dee3fbaa1 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift @@ -187,11 +187,14 @@ public final class TextLineStorage { /// - 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)") - if count == 1 { + guard count > 1 else { removeAll() return } - guard let node = search(for: index)?.node else { return } + guard let node = search(for: index)?.node else { + assertionFailure("Failed to find node for index: \(index)") + return + } count -= 1 length -= node.length height -= node.height @@ -325,7 +328,15 @@ private extension TextLineStorage { replacementNode.leftSubtreeCount = node.leftSubtreeCount replacementNode.leftSubtreeHeight = node.leftSubtreeHeight replacementNode.leftSubtreeOffset = node.leftSubtreeOffset - metaFixup(startingAt: replacementNode, delta: -node.length, deltaHeight: -node.height, nodeAction: .deleted) + // The parent needs to be notified that the replacement node was inserted again, otherwise we lose + // the count of both the node being deleted *and* the replacement node. + // Check that delta > 0 or deltaHeight > 0 before updating. + let delta = -node.length + replacementNode.length + let deltaHeight = -node.height + replacementNode.height + if delta != 0 || deltaHeight != 0 { + // Use nodeAction = .none because the change in number of nodes is 0 + metaFixup(startingAt: replacementNode, delta: delta, deltaHeight: deltaHeight, nodeAction: .none) + } } else { // Either node's left or right is `nil` metaFixup(startingAt: node, delta: -node.length, deltaHeight: -node.height, nodeAction: .deleted) diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift index 938ba41a9..1807cbb3b 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift @@ -140,9 +140,7 @@ public extension TextSelectionManager { return } - if hasFoundValidWordChar && CharacterSet - .whitespacesAndNewlines.subtracting(.newlines) - .union(.punctuationCharacters) + if hasFoundValidWordChar && CharacterSet.punctuationCharacters .isSuperset(of: CharacterSet(charactersIn: substring)) { stop.pointee = true return diff --git a/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift b/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift new file mode 100644 index 000000000..5308a2513 --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift @@ -0,0 +1,47 @@ +// +// TextView+ReplaceCharacters.swift +// +// +// Created by Khan Winter on 9/3/23. +// + +import AppKit +import TextStory + +extension TextView { + // MARK: - Replace Characters + + /// Replace the characters in the given ranges with the given string. + /// - Parameters: + /// - ranges: The ranges to replace + /// - string: The string to insert in the ranges. + public func replaceCharacters(in ranges: [NSRange], with string: String) { + guard isEditable else { return } + layoutManager.beginTransaction() + textStorage.beginEditing() + // Can't insert an empty string into an empty range. One must be not empty + for range in ranges where + (delegate?.textView(self, shouldReplaceContents: range, with: string) ?? true) + && (!range.isEmpty || !string.isEmpty) { + delegate?.textView(self, willReplaceContents: range, with: string) + + layoutManager.willReplaceCharactersInRange(range: range, with: string) + _undoManager?.registerMutation( + TextMutation(string: string as String, range: range, limit: textStorage.length) + ) + textStorage.replaceCharacters(in: range, with: string) + + delegate?.textView(self, didReplaceContents: range, with: string) + } + layoutManager.endTransaction() + textStorage.endEditing() + } + + /// Replace the characters in a range with a new string. + /// - Parameters: + /// - range: The range to replace. + /// - string: The string to insert in the range. + public func replaceCharacters(in range: NSRange, with string: String) { + replaceCharacters(in: [range], with: string) + } +} diff --git a/Sources/CodeEditInputView/TextView/TextView.swift b/Sources/CodeEditInputView/TextView/TextView.swift index 3908909d9..9b002a0e1 100644 --- a/Sources/CodeEditInputView/TextView/TextView.swift +++ b/Sources/CodeEditInputView/TextView/TextView.swift @@ -46,6 +46,8 @@ public class TextView: NSView, NSTextContent { open var contentType: NSTextContentType? + public weak var delegate: TextViewDelegate? + public var textStorage: NSTextStorage! { didSet { setUpLayoutManager() @@ -99,8 +101,10 @@ public class TextView: NSView, NSTextContent { editorOverscroll: CGFloat, isEditable: Bool, letterSpacing: Double, + delegate: TextViewDelegate, storageDelegate: MultiStorageDelegate ) { + self.delegate = delegate self.textStorage = NSTextStorage(string: string) self.storageDelegate = storageDelegate @@ -241,32 +245,6 @@ public class TextView: NSView, NSTextContent { } } - // MARK: - Replace Characters - - public func replaceCharacters(in ranges: [NSRange], with string: String) { - guard isEditable else { return } - layoutManager.beginTransaction() - textStorage.beginEditing() - // 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) - ) - } - layoutManager.endTransaction() - textStorage.endEditing() - } - - public func replaceCharacters(in range: NSRange, with string: String) { - replaceCharacters(in: [range], with: string) - } - - private func replaceCharactersNoCheck(in range: NSRange, with string: String) { - layoutManager.willReplaceCharactersInRange(range: range, with: string) - textStorage.replaceCharacters(in: range, with: string) - } - // MARK: - Layout public override func draw(_ dirtyRect: NSRect) { diff --git a/Sources/CodeEditInputView/TextView/TextViewDelegate.swift b/Sources/CodeEditInputView/TextView/TextViewDelegate.swift new file mode 100644 index 000000000..fcaf385f7 --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextViewDelegate.swift @@ -0,0 +1,20 @@ +// +// TextViewDelegate.swift +// +// +// Created by Khan Winter on 9/3/23. +// + +import Foundation + +public protocol TextViewDelegate: AnyObject { + func textView(_ textView: TextView, willReplaceContents in: NSRange, with: String) + func textView(_ textView: TextView, didReplaceContents in: NSRange, with: String) + func textView(_ textView: TextView, shouldReplaceContents in: NSRange, with: String) -> Bool +} + +public extension TextViewDelegate { + func textView(_ textView: TextView, willReplaceContents in: NSRange, with: String) { } + func textView(_ textView: TextView, didReplaceContents in: NSRange, with: String) { } + func textView(_ textView: TextView, shouldReplaceContents in: NSRange, with: String) -> Bool { true } +} diff --git a/Sources/Common/Extensions/NSRange+Comparable.swift b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+Comparable.swift similarity index 100% rename from Sources/Common/Extensions/NSRange+Comparable.swift rename to Sources/CodeEditTextView/Extensions/NSRange+/NSRange+Comparable.swift diff --git a/Sources/CodeEditTextView/TextViewController.swift b/Sources/CodeEditTextView/TextViewController.swift index 0792679e3..8202b7187 100644 --- a/Sources/CodeEditTextView/TextViewController.swift +++ b/Sources/CodeEditTextView/TextViewController.swift @@ -92,6 +92,7 @@ public class TextViewController: NSViewController { editorOverscroll: editorOverscroll, isEditable: isEditable, letterSpacing: letterSpacing, + delegate: self, storageDelegate: storageDelegate ) textView.postsFrameChangedNotifications = true @@ -211,3 +212,9 @@ extension TextViewController { } } } + +extension TextViewController: TextViewDelegate { + public func textView(_ textView: TextView, didReplaceContents in: NSRange, with: String) { + gutterView.needsDisplay = true + } +} diff --git a/Sources/Common/Extensions/NSTextStorage+getLine.swift b/Sources/Common/Extensions/NSTextStorage+getLine.swift new file mode 100644 index 000000000..94b278370 --- /dev/null +++ b/Sources/Common/Extensions/NSTextStorage+getLine.swift @@ -0,0 +1,28 @@ +// +// File.swift +// +// +// Created by Khan Winter on 9/3/23. +// + +import AppKit + +extension NSString { + public func getNextLine(startingAt location: Int) -> NSRange? { + let range = NSRange(location: location, length: 0) + var end: Int = NSNotFound + var contentsEnd: Int = NSNotFound + self.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 + } + } +} + +extension NSTextStorage { + public func getNextLine(startingAt location: Int) -> NSRange? { + (self.string as NSString).getNextLine(startingAt: location) + } +} diff --git a/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift b/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift index bfa61ee47..09cde7097 100644 --- a/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift +++ b/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift @@ -71,9 +71,12 @@ final class TextLayoutLineStorageTests: XCTestCase { // 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.") + printTree(tree) + tree.delete(lineAt: 45) + printTree(tree) + XCTAssert(tree.root?.right?.left?.length == 11, "Failed to remove node 10") + XCTAssert(tree.root?.right?.leftSubtreeOffset == 20, "Failed to update metadata on parent of node 10") + XCTAssert(tree.root?.right?.left?.right == nil, "Failed to replace node 10 with node 11") XCTAssert(tree.count == 14, "Tree length incorrect") // Delete root @@ -152,3 +155,148 @@ final class TextLayoutLineStorageTests: XCTestCase { } } } + +public func printTree(_ tree: TextLineStorage) { + print( + treeString(tree.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("") +} + +// 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.. Date: Sun, 10 Sep 2023 22:27:57 -0500 Subject: [PATCH 35/75] Finalize Insert, Delete, Newlines, Delete Lines --- .../TextLayoutManager+Edits.swift | 42 +- .../TextLine/LineFragmentView.swift | 3 +- .../TextLine/Typesetter.swift | 21 +- .../TextLineStorage+Iterator.swift | 26 +- .../TextLineStorage+Node.swift | 2 +- .../TextLineStorage+Structs.swift | 18 + .../TextLineStorage/TextLineStorage.swift | 217 ++++++----- ...lectionManager+SelectionManipulation.swift | 3 +- .../TextView/TextView+Delete.swift | 2 +- .../TextView/TextView+Insert.swift | 14 + .../TextView/TextView+NSTextInput.swift | 19 + .../CodeEditTextView/Gutter/GutterView.swift | 6 +- .../CodeEditTextView/TextViewController.swift | 1 - Sources/Common/Extensions/PixelAligned.swift | 22 ++ .../TextLayoutLineStorageTests.swift | 366 +++++++++--------- 15 files changed, 429 insertions(+), 333 deletions(-) create mode 100644 Sources/CodeEditInputView/TextView/TextView+Insert.swift create mode 100644 Sources/Common/Extensions/PixelAligned.swift diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift index fa63c75cc..fe3c94de5 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift @@ -42,22 +42,18 @@ extension TextLayoutManager: NSTextStorageDelegate { // 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) + let lineRange = NSRange(location: index, length: nextLine.max - index) + applyLineInsert((string as NSString).substring(with: lineRange) as NSString, at: range.location + index) index = nextLine.max } - if index < string.lengthOfBytes(using: .utf16) { + if index < (string as NSString).length { // 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 + applyLineInsert( + (string as NSString).substring(from: index) as NSString, + at: range.location + index ) } } @@ -65,6 +61,32 @@ extension TextLayoutManager: NSTextStorageDelegate { setNeedsLayout() } + /// Applies a line insert to the internal line storage tree. + /// - Parameters: + /// - insertedString: The string being inserted. + /// - location: The location the string is being inserted into. + private func applyLineInsert(_ insertedString: NSString, at location: Int) { + if LineEnding(line: insertedString as String) != nil { + // Need to split the line inserting into and create a new line with the split section of the line + guard let linePosition = lineStorage.getLine(atIndex: location) else { return } + let splitLocation = location + insertedString.length + let splitLength = linePosition.range.max - location + let lineDelta = insertedString.length - splitLength // The difference in the line being edited + if lineDelta != 0 { + lineStorage.update(atIndex: location, delta: lineDelta, deltaHeight: 0.0) + } + + lineStorage.insert( + line: TextLine(), + atIndex: splitLocation, + length: splitLength, + height: estimateLineHeight() + ) + } else { + lineStorage.update(atIndex: location, delta: insertedString.length, deltaHeight: 0.0) + } + } + /// 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. diff --git a/Sources/CodeEditInputView/TextLine/LineFragmentView.swift b/Sources/CodeEditInputView/TextLine/LineFragmentView.swift index d551ec2ab..255715ae2 100644 --- a/Sources/CodeEditInputView/TextLine/LineFragmentView.swift +++ b/Sources/CodeEditInputView/TextLine/LineFragmentView.swift @@ -6,6 +6,7 @@ // import AppKit +import Common final class LineFragmentView: NSView { private weak var lineFragment: LineFragment? @@ -33,7 +34,7 @@ final class LineFragmentView: NSView { context.textPosition = CGPoint( x: 0, y: lineFragment.height - lineFragment.descent + (lineFragment.heightDifference/2) - ) + ).pixelAligned CTLineDraw(lineFragment.ctLine, context) context.restoreGState() } diff --git a/Sources/CodeEditInputView/TextLine/Typesetter.swift b/Sources/CodeEditInputView/TextLine/Typesetter.swift index 97d8df5b3..a75329c12 100644 --- a/Sources/CodeEditInputView/TextLine/Typesetter.swift +++ b/Sources/CodeEditInputView/TextLine/Typesetter.swift @@ -71,18 +71,17 @@ final class Typesetter { 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 + guard breakIndex < string.length && breakIndex - 1 > 0 && ensureCharacterCanBreakLine(at: breakIndex - 1) else { + // Walk backwards until we find a valid break point. Max out at 100 characters. + var index = breakIndex - 1 + while index > 0 && breakIndex - index > 100 { + if ensureCharacterCanBreakLine(at: index) { + return index + } else { + index -= 1 + } } - index -= 1 + return breakIndex } return breakIndex diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Iterator.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Iterator.swift index 4c5a89cab..14e85a2eb 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Iterator.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Iterator.swift @@ -8,15 +8,15 @@ import Foundation public extension TextLineStorage { - public func linesStartingAt(_ minY: CGFloat, until maxY: CGFloat) -> TextLineStorageYIterator { + func linesStartingAt(_ minY: CGFloat, until maxY: CGFloat) -> TextLineStorageYIterator { TextLineStorageYIterator(storage: self, minY: minY, maxY: maxY) } - public func linesInRange(_ range: NSRange) -> TextLineStorageRangeIterator { + func linesInRange(_ range: NSRange) -> TextLineStorageRangeIterator { TextLineStorageRangeIterator(storage: self, range: range) } - public struct TextLineStorageYIterator: LazySequenceProtocol, IteratorProtocol { + struct TextLineStorageYIterator: LazySequenceProtocol, IteratorProtocol { private let storage: TextLineStorage private let minY: CGFloat private let maxY: CGFloat @@ -32,9 +32,9 @@ public extension TextLineStorage { public mutating func next() -> TextLinePosition? { if let currentPosition { guard currentPosition.yPos < maxY, - let nextPosition = storage.getLine( - atIndex: currentPosition.range.max - ) else { return nil } + let nextPosition = storage.getLine(atIndex: currentPosition.range.max) else { + return nil + } self.currentPosition = nextPosition return self.currentPosition! } else if let nextPosition = storage.getLine(atPosition: minY) { @@ -46,7 +46,7 @@ public extension TextLineStorage { } } - public struct TextLineStorageRangeIterator: LazySequenceProtocol, IteratorProtocol { + struct TextLineStorageRangeIterator: LazySequenceProtocol, IteratorProtocol { private let storage: TextLineStorage private let range: NSRange private var currentPosition: TextLinePosition? @@ -60,9 +60,9 @@ public extension TextLineStorage { public mutating func next() -> TextLinePosition? { if let currentPosition { guard currentPosition.range.max < range.max, - let nextPosition = storage.getLine( - atIndex: currentPosition.range.max - ) else { return nil } + let nextPosition = storage.getLine(atIndex: currentPosition.range.max) else { + return nil + } self.currentPosition = nextPosition return self.currentPosition! } else if let nextPosition = storage.getLine(atIndex: range.location) { @@ -92,9 +92,9 @@ extension TextLineStorage: LazySequenceProtocol { public mutating func next() -> TextLinePosition? { if let currentPosition { guard currentPosition.range.max < storage.length, - let nextPosition = storage.getLine( - atIndex: currentPosition.range.max - ) else { return nil } + let nextPosition = storage.getLine(atIndex: currentPosition.range.max) else { + return nil + } self.currentPosition = nextPosition return self.currentPosition! } else if let nextPosition = storage.getLine(atIndex: 0) { diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift index b4e3b3dc6..78a0979d2 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift @@ -30,7 +30,7 @@ extension TextLineStorage { /// [c]_/ /// /// ``` - /// + /// - Note: Leaves the task of updating tree metadata to the caller. /// - Parameters: /// - nodeU: The node to replace. /// - nodeV: The node to insert in place of `nodeU` diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Structs.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Structs.swift index f7bf4a77d..27bc2c2d4 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Structs.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Structs.swift @@ -48,6 +48,24 @@ extension TextLineStorage where Data: Identifiable { let index: Int } + internal struct NodeSubtreeMetadata { + let height: CGFloat + let offset: Int + let count: Int + + static var zero: NodeSubtreeMetadata { + NodeSubtreeMetadata(height: 0, offset: 0, count: 0) + } + + static func + (lhs: NodeSubtreeMetadata, rhs: NodeSubtreeMetadata) -> NodeSubtreeMetadata { + NodeSubtreeMetadata( + height: lhs.height + rhs.height, + offset: lhs.offset + rhs.offset, + count: lhs.count + rhs.count + ) + } + } + public struct BuildItem { public let data: Data public let length: Int diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift index dee3fbaa1..1cbb7fde8 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift @@ -44,6 +44,7 @@ public final class TextLineStorage { // MARK: - Public Methods /// Inserts a new line for the given range. + /// - Complexity: `O(log n)` where `n` is the number of lines in the storage object. /// - Parameters: /// - line: The text line to insert /// - range: The range the line represents. If the range is empty the line will be ignored. @@ -94,12 +95,18 @@ public final class TextLineStorage { } } + metaFixup( + startingAt: insertedNode, + delta: insertedNode.length, + deltaHeight: insertedNode.height, + nodeAction: .inserted + ) insertFixup(node: insertedNode) } /// Fetches a line for the given index. /// - /// Complexity: `O(log n)` + /// - 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. public func getLine(atIndex index: Int) -> TextLinePosition? { @@ -109,7 +116,7 @@ public final class TextLineStorage { /// Fetches a line for the given `y` value. /// - /// Complexity: `O(log n)` + /// - 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? { @@ -150,12 +157,11 @@ public final class TextLineStorage { /// 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 encompasses the entire line. /// /// If the delta goes beyond the line's range, an error will be thrown. + /// - 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. /// - Parameters: /// - index: The index where the edit began /// - delta: The change in length of the document. Negative for deletes, positive for insertions. @@ -305,7 +311,6 @@ private extension TextLineStorage { currentYPosition += node.height + (node.right?.leftSubtreeHeight ?? 0) currentIndex += 1 + (node.right?.leftSubtreeCount ?? 0) } else { - print(index, currentOffset, node.length) currentNode = nil } } @@ -314,49 +319,58 @@ private extension TextLineStorage { // 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 - // The parent needs to be notified that the replacement node was inserted again, otherwise we lose - // the count of both the node being deleted *and* the replacement node. - // Check that delta > 0 or deltaHeight > 0 before updating. - let delta = -node.length + replacementNode.length - let deltaHeight = -node.height + replacementNode.height - if delta != 0 || deltaHeight != 0 { - // Use nodeAction = .none because the change in number of nodes is 0 - metaFixup(startingAt: replacementNode, delta: delta, deltaHeight: deltaHeight, nodeAction: .none) - } + /// A basic RB-Tree node removal with specialization for node metadata. + /// - Parameter nodeZ: The node to remove. + func deleteNode(_ nodeZ: Node) { + metaFixup(startingAt: nodeZ, delta: -nodeZ.length, deltaHeight: -nodeZ.height, nodeAction: .deleted) + + var nodeY = nodeZ + var nodeX: Node? + var originalColor = nodeY.color + + if nodeZ.left == nil || nodeZ.right == nil { + nodeX = nodeZ.right ?? nodeZ.left + transplant(nodeZ, with: nodeX) } 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) - } + nodeY = nodeZ.right!.minimum() + + // Delete nodeY from it's original place in the tree. + metaFixup(startingAt: nodeY, delta: -nodeY.length, deltaHeight: -nodeY.height, nodeAction: .deleted) + + originalColor = nodeY.color + nodeX = nodeY.right + if nodeY.parent === nodeZ { + nodeX?.parent = nodeY + } else { + transplant(nodeY, with: nodeY.right) + + nodeY.right?.leftSubtreeCount = nodeY.leftSubtreeCount + nodeY.right?.leftSubtreeHeight = nodeY.leftSubtreeHeight + nodeY.right?.leftSubtreeOffset = nodeY.leftSubtreeOffset + + nodeY.right = nodeZ.right + nodeY.right?.parent = nodeY } + transplant(nodeZ, with: nodeY) + nodeY.left = nodeZ.left + nodeY.left?.parent = nodeY + nodeY.color = nodeZ.color + nodeY.leftSubtreeCount = nodeZ.leftSubtreeCount + nodeY.leftSubtreeHeight = nodeZ.leftSubtreeHeight + nodeY.leftSubtreeOffset = nodeZ.leftSubtreeOffset + + // We've inserted nodeY again into a new spot. Update tree meta + metaFixup(startingAt: nodeY, delta: nodeY.length, deltaHeight: nodeY.height, nodeAction: .inserted) + } + + if originalColor == .black, let nodeX { + deleteFixup(node: nodeX) } } // MARK: - Fixup func insertFixup(node: Node) { - 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 = nodeXParent.sibling() @@ -402,65 +416,61 @@ private extension TextLineStorage { root?.color = .black } - // 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 + var nodeX: Node? = node + while let node = nodeX, node !== root, node.color == .black { + var sibling = node.sibling() + if sibling?.color == .red { + sibling?.color = .black + node.parent?.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) + leftRotate(node: node) } else { - rightRotate(node: sibling) - } - if let newSibling = node.sibling() { - sibling = newSibling + rightRotate(node: node) } + sibling = node.sibling() } - // Case 4: Sibling is black with red child - if let nodeParent = node.parent { - sibling.color = nodeParent.color - nodeParent.color = .black + if sibling?.left?.color == .black && sibling?.right?.color == .black { + sibling?.color = .red + nodeX = node.parent + } else { if isLeftChild(node) { - sibling.right?.color = .black - leftRotate(node: nodeParent) + if sibling?.right?.color == .black { + sibling?.left?.color = .black + sibling?.color = .red + if let sibling { + rightRotate(node: sibling) + } + sibling = node.parent?.right + } + sibling?.color = node.parent?.color ?? .black + node.parent?.color = .black + sibling?.right?.color = .black + leftRotate(node: node) + nodeX = root } else { - sibling.left?.color = .black - rightRotate(node: nodeParent) + if sibling?.left?.color == .black { + sibling?.left?.color = .black + sibling?.color = .red + if let sibling { + leftRotate(node: sibling) + } + sibling = node.parent?.left + } + sibling?.color = node.parent?.color ?? .black + node.parent?.color = .black + sibling?.left?.color = .black + rightRotate(node: node) + nodeX = root } - root?.color = .black - return + + + + } } - node.color = .black + nodeX?.color = .black } /// Walk up the tree, updating any `leftSubtree` metadata. @@ -489,11 +499,6 @@ private extension TextLineStorage { node = node?.parent } } - - func calculateSize(_ node: Node?) -> Int { - guard let node else { return 0 } - return node.length + node.leftSubtreeOffset + calculateSize(node.right) - } } // MARK: - Rotations @@ -512,6 +517,7 @@ private extension TextLineStorage { if left { nodeY = node.right + guard nodeY != nil else { return } nodeY?.leftSubtreeOffset += node.leftSubtreeOffset + node.length nodeY?.leftSubtreeHeight += node.leftSubtreeHeight + node.height nodeY?.leftSubtreeCount += node.leftSubtreeCount + 1 @@ -519,6 +525,7 @@ private extension TextLineStorage { node.right?.parent = node } else { nodeY = node.left + guard nodeY != nil else { return } node.left = nodeY?.right node.left?.parent = node } @@ -526,7 +533,7 @@ private extension TextLineStorage { nodeY?.parent = node.parent if node.parent == nil { if let node = nodeY { - root = node + root = node } } else if isLeftChild(node) { node.parent?.left = nodeY @@ -538,10 +545,24 @@ private extension TextLineStorage { nodeY?.left = node } 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.leftSubtreeCount = (node.left == nil ? 1 : 0) + (node.left?.leftSubtreeCount ?? 0) + let metadata = getSubtreeMeta(startingAt: node.left) + node.leftSubtreeOffset = metadata.offset + node.leftSubtreeHeight = metadata.height + node.leftSubtreeCount = metadata.count } node.parent = nodeY } + + /// Finds the correct subtree metadata starting at a node. + /// - Complexity: `O(log n)` where `n` is the number of nodes in the tree. + /// - Parameter node: The node to start finding metadata for. + /// - Returns: The metadata representing the entire subtree including `node`. + func getSubtreeMeta(startingAt node: Node?) -> NodeSubtreeMetadata { + guard let node else { return .zero } + return NodeSubtreeMetadata( + height: node.height + node.leftSubtreeHeight, + offset: node.length + node.leftSubtreeOffset, + count: 1 + node.leftSubtreeCount + ) + getSubtreeMeta(startingAt: node.right) + } } diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift index 1807cbb3b..83bc60e94 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift @@ -17,7 +17,7 @@ public extension TextSelectionManager { /// - 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( + func rangeOfSelection( from offset: Int, direction: Direction, destination: Destination, @@ -25,6 +25,7 @@ public extension TextSelectionManager { ) -> NSRange { switch direction { case .backward: + guard offset > 0 else { return NSRange(location: offset, length: 0) } // Can't go backwards beyond 0 return extendSelection(from: offset, destination: destination, delta: -1) case .forward: return extendSelection(from: offset, destination: destination, delta: 1) diff --git a/Sources/CodeEditInputView/TextView/TextView+Delete.swift b/Sources/CodeEditInputView/TextView/TextView+Delete.swift index 7a7239f15..71b4fb140 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Delete.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Delete.swift @@ -56,9 +56,9 @@ extension TextView { direction: direction, destination: destination ) + guard extendedRange.location >= 0 else { continue } textSelection.range.formUnion(extendedRange) } - print(#function, selectionManager.textSelections.map(\.range)) replaceCharacters(in: selectionManager.textSelections.map(\.range), with: "") } } diff --git a/Sources/CodeEditInputView/TextView/TextView+Insert.swift b/Sources/CodeEditInputView/TextView/TextView+Insert.swift new file mode 100644 index 000000000..f31b55f15 --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+Insert.swift @@ -0,0 +1,14 @@ +// +// TextView+Insert.swift +// +// +// Created by Khan Winter on 9/3/23. +// + +import AppKit + +extension TextView { + public override func insertNewline(_ sender: Any?) { + insertText(layoutManager.detectedLineEnding.rawValue) + } +} diff --git a/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift b/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift index 8fd1c239b..8651151d8 100644 --- a/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift +++ b/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift @@ -64,6 +64,25 @@ extension TextView: NSTextInputClient { } } + public override func insertText(_ insertString: Any) { + var string: String + switch insertString { + case let insertString as NSString: + string = insertString as String + case let insertString as NSAttributedString: + string = insertString.string + default: + string = "" + assertionFailure("\(#function) called with invalid string type. Expected String or NSAttributedString.") + } + + if LineEnding(rawValue: string) == .cr && layoutManager.detectedLineEnding == .crlf { + string = LineEnding.crlf.rawValue + } + + replaceCharacters(in: selectionManager.textSelections.map(\.range), with: string) + } + // MARK: - Marked Text /// Replaces a specified range in the receiver’s text storage with the given string and sets the selection. diff --git a/Sources/CodeEditTextView/Gutter/GutterView.swift b/Sources/CodeEditTextView/Gutter/GutterView.swift index 2e3ece3f5..67df29021 100644 --- a/Sources/CodeEditTextView/Gutter/GutterView.swift +++ b/Sources/CodeEditTextView/Gutter/GutterView.swift @@ -7,6 +7,7 @@ import AppKit import CodeEditInputView +import Common public class GutterView: NSView { struct EdgeInsets: Equatable, Hashable { @@ -151,10 +152,7 @@ public class GutterView: NSView { // Leading padding + (width - linewidth) let xPos = edgeInsets.leading + (maxWidth - lineNumberWidth) - context.textPosition = CGPoint( - x: xPos, - y: yPos - ) + context.textPosition = CGPoint(x: xPos, y: yPos).pixelAligned CTLineDraw(ctLine, context) } context.restoreGState() diff --git a/Sources/CodeEditTextView/TextViewController.swift b/Sources/CodeEditTextView/TextViewController.swift index 8202b7187..f51ab77dc 100644 --- a/Sources/CodeEditTextView/TextViewController.swift +++ b/Sources/CodeEditTextView/TextViewController.swift @@ -173,7 +173,6 @@ extension TextViewController: ThemeAttributesProviding { [ .font: font, .foregroundColor: theme.colorFor(capture), -// .baselineOffset: baselineOffset, // .paragraphStyle: paragraphStyle, // .kern: kern ] diff --git a/Sources/Common/Extensions/PixelAligned.swift b/Sources/Common/Extensions/PixelAligned.swift new file mode 100644 index 000000000..d085e3040 --- /dev/null +++ b/Sources/Common/Extensions/PixelAligned.swift @@ -0,0 +1,22 @@ +// +// File.swift +// +// +// Created by Khan Winter on 9/10/23. +// + +import Foundation + +public extension NSRect { + /// Creates a rect pixel-aligned on all edges. + var pixelAligned: NSRect { + NSIntegralRectWithOptions(self, .alignAllEdgesNearest) + } +} + +public extension NSPoint { + /// Creates a point that's pixel-aligned. + var pixelAligned: NSPoint { + NSIntegralRectWithOptions(NSRect(x: self.x, y: self.y, width: 0, height: 0), .alignAllEdgesNearest).origin + } +} diff --git a/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift b/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift index 09cde7097..e6b955047 100644 --- a/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift +++ b/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift @@ -1,51 +1,161 @@ import XCTest @testable import CodeEditInputView +fileprivate extension CGFloat { + func approxEqual(_ value: CGFloat) -> Bool { + return abs(self - value) < 0.05 + } +} + final class TextLayoutLineStorageTests: XCTestCase { - func test_insert() { + /// Creates a balanced height=3 tree useful for testing and debugging. + /// - Returns: A new tree. + fileprivate func createBalancedTree() -> TextLineStorage { let tree = TextLineStorage() - var sum = 0 - for i in 0..<20 { - tree.insert( - line: TextLine(), - atIndex: sum, - length: i + 1, - height: 1.0 - ) - sum += i + 1 + var data = [TextLineStorage.BuildItem]() + for i in 0..<15 { + data.append(.init(data: TextLine(), length: i + 1)) } - 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.") + tree.build(from: data, estimatedLineHeight: 1.0) + return tree } - func test_update() { - let tree = TextLineStorage() - var sum = 0 - for i in 0..<20 { - tree.insert( - line: TextLine(), - atIndex: sum, - length: i + 1, - height: 1.0 + /// Recursively checks that the given tree has the correct metadata everywhere. + /// - Parameter tree: The tree to check. + fileprivate func assertTreeMetadataCorrect(_ tree: TextLineStorage) throws { + struct ChildData { + let length: Int + let count: Int + let height: CGFloat + } + + func checkChildren(_ node: TextLineStorage.Node?) -> ChildData { + guard let node else { return ChildData(length: 0, count: 0, height: 0.0) } + let leftSubtreeData = checkChildren(node.left) + let rightSubtreeData = checkChildren(node.right) + + XCTAssert(leftSubtreeData.length == node.leftSubtreeOffset, "Left subtree length incorrect") + XCTAssert(leftSubtreeData.count == node.leftSubtreeCount, "Left subtree node count incorrect") + XCTAssert(leftSubtreeData.height.approxEqual(node.leftSubtreeHeight), "Left subtree height incorrect") + + return ChildData( + length: node.length + leftSubtreeData.length + rightSubtreeData.length, + count: 1 + leftSubtreeData.count + rightSubtreeData.count, + height: node.height + leftSubtreeData.height + rightSubtreeData.height ) - sum += i + 1 } - tree.update(atIndex: 7, delta: 1, deltaHeight: 0) - // TODO: -// XCTAssert(tree.getLine(atIndex: 7)?.range.length == 8, "") + + let rootData = checkChildren(tree.root) + + XCTAssert(rootData.count == tree.count, "Node count incorrect") + XCTAssert(rootData.length == tree.length, "Length incorrect") + XCTAssert(rootData.height.approxEqual(tree.height), "Height incorrect") + + var lastIdx = -1 + for line in tree { + XCTAssert(lastIdx == line.index - 1, "Incorrect index found") + lastIdx = line.index + } } - func test_delete() { + func test_search() { var tree = TextLineStorage() - 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 + tree.insert(line: TextLine(), atIndex: 0, length: 1, height: 1.0) + tree.insert(line: TextLine(), atIndex: 1, length: 2, height: 1.0) + tree.insert(line: TextLine(), atIndex: 0, length: 3, height: 1.0) + tree.insert(line: TextLine(), atIndex: 0, length: 4, height: 1.0) + tree.insert(line: TextLine(), atIndex: 7, length: 5, height: 1.0) + tree.insert(line: TextLine(), atIndex: 12, length: 6, height: 1.0) + printTree(tree) + for line in tree { + print(line.index) } + } + + func test_insert() throws { + var tree = TextLineStorage() + + // Single Element + tree.insert(line: TextLine(), atIndex: 0, length: 1, height: 50.0) + XCTAssert(tree.length == 1, "Tree length incorrect") + XCTAssert(tree.count == 1, "Tree count incorrect") + XCTAssert(tree.height == 50.0, "Tree height incorrect") + XCTAssert(tree.root?.right == nil && tree.root?.left == nil, "Somehow inserted an extra node.") + try assertTreeMetadataCorrect(tree) + + // Insert into first + tree = createBalancedTree() + tree.insert(line: TextLine(), atIndex: 0, length: 1, height: 1.0) + try assertTreeMetadataCorrect(tree) + + // Insert into last + tree = createBalancedTree() + tree.insert(line: TextLine(), atIndex: tree.length - 1, length: 1, height: 1.0) + try assertTreeMetadataCorrect(tree) + + // + tree = createBalancedTree() + tree.insert(line: TextLine(), atIndex: 45, length: 1, height: 1.0) + try assertTreeMetadataCorrect(tree) + } + + func test_update() throws { + var tree = TextLineStorage() + + // Single Element + tree.insert(line: TextLine(), atIndex: 0, length: 1, height: 1.0) + tree.update(atIndex: 0, delta: 20, deltaHeight: 5.0) + XCTAssert(tree.length == 21, "Tree length incorrect") + XCTAssert(tree.count == 1, "Tree count incorrect") + XCTAssert(tree.height == 6, "Tree height incorrect") + XCTAssert(tree.root?.right == nil && tree.root?.left == nil, "Somehow inserted an extra node.") + try assertTreeMetadataCorrect(tree) + + // Update First + tree = createBalancedTree() + tree.update(atIndex: 0, delta: 12, deltaHeight: -0.5) + XCTAssert(tree.height == 14.5, "Tree height incorrect") + XCTAssert(tree.count == 15, "Tree count changed") + XCTAssert(tree.length == 132, "Tree length incorrect") + XCTAssert(tree.first?.range.length == 13, "First node wasn't updated correctly.") + try assertTreeMetadataCorrect(tree) + + // Update Last + tree = createBalancedTree() + tree.update(atIndex: tree.length - 1, delta: -14, deltaHeight: 1.75) + XCTAssert(tree.height == 16.75, "Tree height incorrect") + XCTAssert(tree.count == 15, "Tree count changed") + XCTAssert(tree.length == 106, "Tree length incorrect") + XCTAssert(tree.last?.range.length == 1, "Last node wasn't updated correctly.") + try assertTreeMetadataCorrect(tree) + + // Update middle + tree = createBalancedTree() + tree.update(atIndex: 45, delta: -9, deltaHeight: 1.0) + XCTAssert(tree.height == 16.0, "Tree height incorrect") + XCTAssert(tree.count == 15, "Tree count changed") + XCTAssert(tree.length == 111, "Tree length incorrect") + XCTAssert(tree.root?.right?.left?.height == 2.0 && tree.root?.right?.left?.length == 1, "Node wasn't updated") + try assertTreeMetadataCorrect(tree) + + // Update at random + tree = createBalancedTree() + for _ in 0..<20 { + let delta = Int.random(in: 1..<20) + let deltaHeight = Double.random(in: 0..<20.0) + let originalHeight = tree.height + let originalCount = tree.count + let originalLength = tree.length + tree.update(atIndex: Int.random(in: 0..() // Single Element tree.insert(line: TextLine(), atIndex: 0, length: 1, height: 1.0) @@ -53,35 +163,46 @@ final class TextLayoutLineStorageTests: XCTestCase { tree.delete(lineAt: 0) XCTAssert(tree.length == 0, "Tree failed to delete single node") XCTAssert(tree.root == nil, "Tree root should be nil") + try assertTreeMetadataCorrect(tree) // Delete first - tree = createTree() + tree = createBalancedTree() tree.delete(lineAt: 0) XCTAssert(tree.count == 14, "Tree length incorrect") XCTAssert(tree.first?.range.length == 2, "Failed to delete leftmost node") + try assertTreeMetadataCorrect(tree) // Delete last - tree = createTree() + tree = createBalancedTree() tree.delete(lineAt: tree.length - 1) XCTAssert(tree.count == 14, "Tree length incorrect") XCTAssert(tree.last?.range.length == 14, "Failed to delete rightmost node") + try assertTreeMetadataCorrect(tree) // Delete mid leaf - tree = createTree() - printTree(tree) + tree = createBalancedTree() tree.delete(lineAt: 45) - printTree(tree) XCTAssert(tree.root?.right?.left?.length == 11, "Failed to remove node 10") XCTAssert(tree.root?.right?.leftSubtreeOffset == 20, "Failed to update metadata on parent of node 10") XCTAssert(tree.root?.right?.left?.right == nil, "Failed to replace node 10 with node 11") XCTAssert(tree.count == 14, "Tree length incorrect") + try assertTreeMetadataCorrect(tree) + + tree = createBalancedTree() + tree.delete(lineAt: 66) + XCTAssert(tree.root?.right?.length == 13, "Failed to remove node 12") + XCTAssert(tree.root?.right?.leftSubtreeOffset == 30, "Failed to update metadata on parent of node 13") + XCTAssert(tree.root?.right?.left?.right?.left == nil, "Failed to replace node 12 with node 13") + XCTAssert(tree.count == 14, "Tree length incorrect") + try assertTreeMetadataCorrect(tree) + // Delete root - tree = createTree() + tree = createBalancedTree() 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") @@ -89,17 +210,23 @@ final class TextLayoutLineStorageTests: XCTestCase { 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") + try assertTreeMetadataCorrect(tree) // Delete a bunch of random for _ in 0..<20 { - tree = createTree() - tree.delete(lineAt: Int.random(in: 0.. last, "Out of order after deletion") - last = line.range.length + tree = createBalancedTree() + var lastCount = 15 + while !tree.isEmpty { + lastCount -= 1 + tree.delete(lineAt: Int.random(in: 0.. last, "Out of order after deletion") + last = line.range.length + } + try assertTreeMetadataCorrect(tree) } } } @@ -155,148 +282,3 @@ final class TextLayoutLineStorageTests: XCTestCase { } } } - -public func printTree(_ tree: TextLineStorage) { - print( - treeString(tree.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("") -} - -// 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.. Date: Mon, 11 Sep 2023 18:40:27 -0500 Subject: [PATCH 36/75] Arrow Left & Right, Fix Lint Errors, Trailing Insets --- .../TextLayoutManager+Iterator.swift | 4 +- .../TextLayoutManager/TextLayoutManager.swift | 32 +++-- .../TextLineStorage+Node.swift | 10 +- .../TextLineStorage/TextLineStorage.swift | 10 +- ...lectionManager+SelectionManipulation.swift | 12 +- .../TextSelectionManager.swift | 2 +- .../TextView/TextView+Insert.swift | 6 +- .../TextView/TextView+Move.swift | 131 ++++++++++++++++++ .../TextView/TextView+NSTextInput.swift | 12 +- .../TextView/TextView+ReplaceCharacters.swift | 6 +- .../TextView/TextView+UndoRedo.swift | 2 +- .../CodeEditInputView/TextView/TextView.swift | 21 ++- .../TextView/TextViewDelegate.swift | 12 +- .../Utils/CEUndoManager.swift | 15 +- .../Utils/HorizontalEdgeInsets.swift | 32 +++++ .../CodeEditInputView/Utils/LineEnding.swift | 25 ++-- .../CodeEditTextView/CodeEditTextView.swift | 1 - .../STTextViewController+Lifecycle.swift | 6 +- .../Controller/STTextViewController.swift | 2 +- .../STTextView+TextInterface.swift | 2 +- .../CodeEditTextView/Gutter/GutterView.swift | 4 +- .../Highlighting/Highlighter.swift | 6 +- .../CodeEditTextView/TextViewController.swift | 7 +- .../Extensions/NSTextStorage+getLine.swift | 2 +- Sources/Common/Extensions/PixelAligned.swift | 2 +- 25 files changed, 275 insertions(+), 89 deletions(-) create mode 100644 Sources/CodeEditInputView/TextView/TextView+Move.swift create mode 100644 Sources/CodeEditInputView/Utils/HorizontalEdgeInsets.swift diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Iterator.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Iterator.swift index 0317194b7..40f6f738c 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Iterator.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Iterator.swift @@ -8,7 +8,7 @@ import Foundation public extension TextLayoutManager { - public func visibleLines() -> Iterator { + func visibleLines() -> Iterator { let visibleRect = delegate?.visibleRect ?? NSRect( x: 0, y: 0, @@ -18,7 +18,7 @@ public extension TextLayoutManager { return Iterator(minY: max(visibleRect.minY, 0), maxY: max(visibleRect.maxY, 0), storage: self.lineStorage) } - public struct Iterator: LazySequenceProtocol, IteratorProtocol { + struct Iterator: LazySequenceProtocol, IteratorProtocol { private var storageIterator: TextLineStorage.TextLineStorageYIterator init(minY: CGFloat, maxY: CGFloat, storage: TextLineStorage) { diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift index a87d4795f..dcc5165d5 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift @@ -26,8 +26,9 @@ public class TextLayoutManager: NSObject { public var typingAttributes: [NSAttributedString.Key: Any] public var lineHeightMultiplier: CGFloat public var wrapLines: Bool - public var detectedLineEnding: LineEnding = .lf - public var gutterWidth: CGFloat = 20 { + public var detectedLineEnding: LineEnding = .lineFeed + /// The edge insets to inset all text layout with. + public var edgeInsets: HorizontalEdgeInsets = .zero { didSet { setNeedsLayout() } @@ -46,7 +47,7 @@ public 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 + private(set) public var isInTransaction: Bool = false weak private var layoutView: NSView? @@ -136,7 +137,7 @@ public class TextLayoutManager: NSObject { } let fragment = fragmentPosition.data - if fragment.width < point.x - gutterWidth { + if fragment.width < point.x - edgeInsets.left { let fragmentRange = CTLineGetStringRange(fragment.ctLine) // Return eol return position.range.location + fragmentRange.location + fragmentRange.length - ( @@ -148,7 +149,7 @@ public class TextLayoutManager: NSObject { // Somewhere in the fragment let fragmentIndex = CTLineGetStringIndexForPosition( fragment.ctLine, - CGPoint(x: point.x - gutterWidth, y: fragment.height/2) + CGPoint(x: point.x - edgeInsets.left, y: fragment.height/2) ) return position.range.location + fragmentIndex } @@ -183,9 +184,9 @@ public class TextLayoutManager: NSObject { ) return CGRect( - x: minXPos + gutterWidth, + x: minXPos + edgeInsets.left, y: linePosition.yPos + fragmentPosition.yPos, - width: (maxXPos - minXPos) + gutterWidth, + width: (maxXPos - minXPos) + edgeInsets.left, height: fragmentPosition.data.scaledHeight ) } @@ -218,12 +219,12 @@ public class TextLayoutManager: NSObject { /// 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() { + public func beginTransaction() { isInTransaction = true } /// Ends a transaction. When called, the layout manager will layout any necessary lines. - func endTransaction() { + public func endTransaction() { isInTransaction = false setNeedsLayout() layoutLines() @@ -232,7 +233,7 @@ public class TextLayoutManager: NSObject { // MARK: - Layout /// Lays out all visible lines - internal func layoutLines() { + internal func layoutLines() { // swiftlint:disable:this function_body_length guard let visibleRect = delegate?.visibleRect, !isInTransaction else { return } let minY = max(visibleRect.minY, 0) let maxY = max(visibleRect.maxY, 0) @@ -240,13 +241,16 @@ public class TextLayoutManager: NSObject { var usedFragmentIDs = Set() var forceLayout: Bool = needsLayout let maxWidth: CGFloat = wrapLines - ? (delegate?.textViewSize().width ?? .greatestFiniteMagnitude) - gutterWidth - : .greatestFiniteMagnitude + ? (delegate?.textViewSize().width ?? .greatestFiniteMagnitude) - edgeInsets.horizontal + : .greatestFiniteMagnitude var newVisibleLines: Set = [] var yContentAdjustment: CGFloat = 0 // Layout all lines for linePosition in lineStorage.linesStartingAt(minY, until: maxY) { + // Updating height in the loop may cause the iterator to be wrong + guard linePosition.yPos < maxY else { break } + if forceLayout || linePosition.data.needsLayout(maxWidth: maxWidth) || !visibleLineIds.contains(linePosition.data.id) { @@ -271,7 +275,7 @@ public class TextLayoutManager: NSObject { yContentAdjustment += lineSize.height - linePosition.height } } - if maxLineWidth < lineSize.width { + if maxLineWidth < lineSize.width + edgeInsets.horizontal { maxLineWidth = lineSize.width } } else { @@ -347,7 +351,7 @@ public class TextLayoutManager: NSObject { ) { let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id) view.setLineFragment(lineFragment.data) - view.frame.origin = CGPoint(x: gutterWidth, y: yPos) + view.frame.origin = CGPoint(x: edgeInsets.left, y: yPos) layoutView?.addSubview(view) view.needsDisplay = true } diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift index 78a0979d2..6afb11643 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift @@ -45,12 +45,12 @@ extension TextLineStorage { nodeV?.parent = nodeU.parent } - final class Node { - enum Color { - case red - case black - } + enum Color { + case red + case black + } + final class Node { // The length of the text line var length: Int // The height of this text line diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift index 1cbb7fde8..43e1ca467 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift @@ -7,6 +7,10 @@ import Foundation +// Disabling the file length here due to the fact that we want to keep certain methods private even to this package. +// Specifically, all rotation methods, fixup methods, and internal search methods must be kept private. +// swiftlint:disable file_length + /// Implements a red-black tree for efficiently editing, storing and retrieving lines of text in a document. public final class TextLineStorage { private enum MetaFixupAction { @@ -464,10 +468,6 @@ private extension TextLineStorage { rightRotate(node: node) nodeX = root } - - - - } } nodeX?.color = .black @@ -566,3 +566,5 @@ private extension TextLineStorage { ) + getSubtreeMeta(startingAt: node.right) } } + +// swiftlint:enable file_length diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift index 83bc60e94..35c282b89 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift @@ -124,15 +124,13 @@ public extension TextSelectionManager { 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 + location: delta > 0 ? offset : 0, + length: delta > 0 ? string.length - offset : offset ), options: enumerationOptions ) { substring, _, _, stop in @@ -142,6 +140,7 @@ public extension TextSelectionManager { } if hasFoundValidWordChar && CharacterSet.punctuationCharacters + .union(.whitespacesAndNewlines) .isSuperset(of: CharacterSet(charactersIn: substring)) { stop.pointee = true return @@ -188,7 +187,10 @@ public extension TextSelectionManager { return NSRange(location: offset, length: 0) } let lineBound = delta > 0 - ? line.range.location + lineFragment.range.max + ? line.range.location + min( + lineFragment.range.max, + line.range.max - line.range.location - (layoutManager?.detectedLineEnding.length ?? 1) + ) : line.range.location + lineFragment.range.location var foundRange = NSRange( diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift index 4e72418f7..569c91efa 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift @@ -103,7 +103,7 @@ public class TextSelectionManager: NSObject { // MARK: - Selection Views - internal func updateSelectionViews() { + func updateSelectionViews() { var didUpdate: Bool = false for textSelection in textSelections where textSelection.range.isEmpty { diff --git a/Sources/CodeEditInputView/TextView/TextView+Insert.swift b/Sources/CodeEditInputView/TextView/TextView+Insert.swift index f31b55f15..8d87288e0 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Insert.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Insert.swift @@ -8,7 +8,11 @@ import AppKit extension TextView { - public override func insertNewline(_ sender: Any?) { + override public func insertNewline(_ sender: Any?) { insertText(layoutManager.detectedLineEnding.rawValue) } + + override public func insertTab(_ sender: Any?) { + insertText("\t") + } } diff --git a/Sources/CodeEditInputView/TextView/TextView+Move.swift b/Sources/CodeEditInputView/TextView/TextView+Move.swift new file mode 100644 index 000000000..fd5f64f43 --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+Move.swift @@ -0,0 +1,131 @@ +// +// File.swift +// +// +// Created by Khan Winter on 9/10/23. +// + +import Foundation + +extension TextView { + override public func moveUp(_ sender: Any?) { + moveSelections(direction: .up, destination: .character) + } + + override public func moveUpAndModifySelection(_ sender: Any?) { + moveSelections(direction: .up, destination: .character, modifySelection: true) + } + + override public func moveDown(_ sender: Any?) { + moveSelections(direction: .down, destination: .character) + } + + override public func moveDownAndModifySelection(_ sender: Any?) { + moveSelections(direction: .down, destination: .character, modifySelection: true) + } + + override public func moveLeft(_ sender: Any?) { + selectionManager.textSelections.forEach { selection in + if selection.range.isEmpty { + moveSelection(selection: selection, direction: .backward, destination: .character) + } else { + selection.range.location = selection.range.max + selection.range.length = 0 + } + } + selectionManager.updateSelectionViews() + setNeedsDisplay() + } + + override public func moveLeftAndModifySelection(_ sender: Any?) { + moveSelections(direction: .backward, destination: .character, modifySelection: true) + } + + override public func moveRight(_ sender: Any?) { + selectionManager.textSelections.forEach { selection in + if selection.range.isEmpty { + moveSelection(selection: selection, direction: .forward, destination: .character) + } else { + selection.range.length = 0 + } + } + selectionManager.updateSelectionViews() + setNeedsDisplay() + } + + override public func moveRightAndModifySelection(_ sender: Any?) { + moveSelections(direction: .forward, destination: .character, modifySelection: true) + } + + override public func moveWordLeft(_ sender: Any?) { + moveSelections(direction: .backward, destination: .word) + } + + override public func moveWordLeftAndModifySelection(_ sender: Any?) { + moveSelections(direction: .backward, destination: .word, modifySelection: true) + } + + override public func moveWordRight(_ sender: Any?) { + moveSelections(direction: .forward, destination: .word) + } + + override public func moveWordRightAndModifySelection(_ sender: Any?) { + moveSelections(direction: .forward, destination: .word, modifySelection: true) + } + + override public func moveToLeftEndOfLine(_ sender: Any?) { + moveSelections(direction: .backward, destination: .line) + } + + override public func moveToLeftEndOfLineAndModifySelection(_ sender: Any?) { + moveSelections(direction: .backward, destination: .line, modifySelection: true) + } + + override public func moveToRightEndOfLine(_ sender: Any?) { + moveSelections(direction: .forward, destination: .line) + } + + override public func moveToRightEndOfLineAndModifySelection(_ sender: Any?) { + moveSelections(direction: .forward, destination: .line, modifySelection: true) + } + + fileprivate func moveSelections( + direction: TextSelectionManager.Direction, + destination: TextSelectionManager.Destination, + modifySelection: Bool = false + ) { + selectionManager.textSelections.forEach { + moveSelection( + selection: $0, + direction: direction, + destination: destination, + modifySelection: modifySelection + ) + } + selectionManager.updateSelectionViews() + setNeedsDisplay() + } + + fileprivate func moveSelection( + selection: TextSelectionManager.TextSelection, + direction: TextSelectionManager.Direction, + destination: TextSelectionManager.Destination, + modifySelection: Bool = false + ) { + let range = selectionManager.rangeOfSelection( + from: direction == .forward ? selection.range.max : selection.range.location, + direction: direction, + destination: destination + ) + if modifySelection { + selection.range.formUnion(range) + } else { + switch direction { + case .up, .down, .backward: + selection.range = NSRange(location: range.location, length: 0) + case .forward: + selection.range = NSRange(location: range.max, length: 0) + } + } + } +} diff --git a/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift b/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift index 8651151d8..22deb6fcc 100644 --- a/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift +++ b/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift @@ -53,8 +53,9 @@ 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 LineEnding(rawValue: insertString) == .carriageReturn + && layoutManager.detectedLineEnding == .carriageReturnLineFeed { + insertString = LineEnding.carriageReturnLineFeed.rawValue } if replacementRange.location == NSNotFound { @@ -64,7 +65,7 @@ extension TextView: NSTextInputClient { } } - public override func insertText(_ insertString: Any) { + override public func insertText(_ insertString: Any) { var string: String switch insertString { case let insertString as NSString: @@ -76,8 +77,9 @@ extension TextView: NSTextInputClient { assertionFailure("\(#function) called with invalid string type. Expected String or NSAttributedString.") } - if LineEnding(rawValue: string) == .cr && layoutManager.detectedLineEnding == .crlf { - string = LineEnding.crlf.rawValue + if LineEnding(rawValue: string) == .carriageReturn + && layoutManager.detectedLineEnding == .carriageReturnLineFeed { + string = LineEnding.carriageReturnLineFeed.rawValue } replaceCharacters(in: selectionManager.textSelections.map(\.range), with: string) diff --git a/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift b/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift index 5308a2513..0a5a691d2 100644 --- a/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift +++ b/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift @@ -21,9 +21,9 @@ extension TextView { textStorage.beginEditing() // Can't insert an empty string into an empty range. One must be not empty for range in ranges where - (delegate?.textView(self, shouldReplaceContents: range, with: string) ?? true) + (delegate?.textView(self, shouldReplaceContentsIn: range, with: string) ?? true) && (!range.isEmpty || !string.isEmpty) { - delegate?.textView(self, willReplaceContents: range, with: string) + delegate?.textView(self, willReplaceContentsIn: range, with: string) layoutManager.willReplaceCharactersInRange(range: range, with: string) _undoManager?.registerMutation( @@ -31,7 +31,7 @@ extension TextView { ) textStorage.replaceCharacters(in: range, with: string) - delegate?.textView(self, didReplaceContents: range, with: string) + delegate?.textView(self, didReplaceContentsIn: range, with: string) } layoutManager.endTransaction() textStorage.endEditing() diff --git a/Sources/CodeEditInputView/TextView/TextView+UndoRedo.swift b/Sources/CodeEditInputView/TextView/TextView+UndoRedo.swift index d2ff83b23..830a66741 100644 --- a/Sources/CodeEditInputView/TextView/TextView+UndoRedo.swift +++ b/Sources/CodeEditInputView/TextView/TextView+UndoRedo.swift @@ -8,7 +8,7 @@ import AppKit extension TextView { - public override var undoManager: UndoManager? { + override public var undoManager: UndoManager? { _undoManager?.manager } diff --git a/Sources/CodeEditInputView/TextView/TextView.swift b/Sources/CodeEditInputView/TextView/TextView.swift index 9b002a0e1..8db899965 100644 --- a/Sources/CodeEditInputView/TextView/TextView.swift +++ b/Sources/CodeEditInputView/TextView/TextView.swift @@ -43,6 +43,13 @@ public class TextView: NSView, NSTextContent { @Invalidating(.display) public var isSelectable: Bool = true public var letterSpacing: Double + public var edgeInsets: HorizontalEdgeInsets = .zero { + didSet { + layoutManager.edgeInsets = edgeInsets + selectionManager.updateSelectionViews() + setNeedsDisplay() + } + } open var contentType: NSTextContentType? @@ -143,7 +150,7 @@ public class TextView: NSView, NSTextContent { layoutManager = TextLayoutManager( textStorage: textStorage, typingAttributes: [ - .font: font, + .font: font ], lineHeightMultiplier: lineHeight, wrapLines: wrapLines, @@ -203,19 +210,19 @@ public class TextView: NSView, NSTextContent { // MARK: - View Lifecycle - public override func viewWillMove(toWindow newWindow: NSWindow?) { + override public func viewWillMove(toWindow newWindow: NSWindow?) { super.viewWillMove(toWindow: newWindow) layoutManager.layoutLines() } - public override func viewDidEndLiveResize() { + override public func viewDidEndLiveResize() { super.viewDidEndLiveResize() updateFrameIfNeeded() } // MARK: - Interaction - public override func keyDown(with event: NSEvent) { + override public func keyDown(with event: NSEvent) { guard isEditable else { super.keyDown(with: event) return @@ -230,7 +237,7 @@ public class TextView: NSView, NSTextContent { } } - public override func mouseDown(with event: NSEvent) { + override public func mouseDown(with event: NSEvent) { // Set cursor guard let offset = layoutManager.textOffsetAtPoint(self.convert(event.locationInWindow, from: nil)) else { super.mouseDown(with: event) @@ -247,7 +254,7 @@ public class TextView: NSView, NSTextContent { // MARK: - Layout - public override func draw(_ dirtyRect: NSRect) { + override public func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) if isSelectable { selectionManager.drawSelections(in: dirtyRect) @@ -258,7 +265,7 @@ public class TextView: NSView, NSTextContent { true } - public override var visibleRect: NSRect { + override public var visibleRect: NSRect { if let scrollView = scrollView { var rect = scrollView.documentVisibleRect rect.origin.y += scrollView.contentInsets.top diff --git a/Sources/CodeEditInputView/TextView/TextViewDelegate.swift b/Sources/CodeEditInputView/TextView/TextViewDelegate.swift index fcaf385f7..36c03eefe 100644 --- a/Sources/CodeEditInputView/TextView/TextViewDelegate.swift +++ b/Sources/CodeEditInputView/TextView/TextViewDelegate.swift @@ -8,13 +8,13 @@ import Foundation public protocol TextViewDelegate: AnyObject { - func textView(_ textView: TextView, willReplaceContents in: NSRange, with: String) - func textView(_ textView: TextView, didReplaceContents in: NSRange, with: String) - func textView(_ textView: TextView, shouldReplaceContents in: NSRange, with: String) -> Bool + func textView(_ textView: TextView, willReplaceContentsIn range: NSRange, with: String) + func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with: String) + func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with: String) -> Bool } public extension TextViewDelegate { - func textView(_ textView: TextView, willReplaceContents in: NSRange, with: String) { } - func textView(_ textView: TextView, didReplaceContents in: NSRange, with: String) { } - func textView(_ textView: TextView, shouldReplaceContents in: NSRange, with: String) -> Bool { true } + func textView(_ textView: TextView, willReplaceContentsIn range: NSRange, with: String) { } + func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with: String) { } + func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with: String) -> Bool { true } } diff --git a/Sources/CodeEditInputView/Utils/CEUndoManager.swift b/Sources/CodeEditInputView/Utils/CEUndoManager.swift index 794d7fbfd..d68f1bad7 100644 --- a/Sources/CodeEditInputView/Utils/CEUndoManager.swift +++ b/Sources/CodeEditInputView/Utils/CEUndoManager.swift @@ -46,14 +46,15 @@ class CEUndoManager { /// Represents a group of mutations that should be treated as one mutation when undoing/redoing. private struct UndoGroup { - struct Mutation { - var mutation: TextMutation - var inverse: TextMutation - } - var mutations: [Mutation] } + /// A single undo mutation. + private struct Mutation { + var mutation: TextMutation + var inverse: TextMutation + } + public let manager: DelegatedUndoManager public var isUndoing: Bool = false public var isRedoing: Bool = false @@ -128,7 +129,7 @@ class CEUndoManager { !isRedoing else { return } - let newMutation = UndoGroup.Mutation(mutation: mutation, inverse: textStorage.inverseMutation(for: mutation)) + let newMutation = Mutation(mutation: mutation, inverse: textStorage.inverseMutation(for: mutation)) if !undoStack.isEmpty, let lastMutation = undoStack.last?.mutations.last { if isGrouping || shouldContinueGroup(newMutation, lastMutation: lastMutation) { undoStack[undoStack.count - 1].mutations.append(newMutation) @@ -166,7 +167,7 @@ class CEUndoManager { /// - mutation: The current mutation. /// - lastMutation: The last mutation applied to the document. /// - Returns: Whether or not the given mutations can be grouped. - private func shouldContinueGroup(_ mutation: UndoGroup.Mutation, lastMutation: UndoGroup.Mutation) -> Bool { + private func shouldContinueGroup(_ mutation: Mutation, lastMutation: Mutation) -> Bool { // If last mutation was delete & new is insert or vice versa, split group if (mutation.mutation.range.length > 0 && lastMutation.mutation.range.length == 0) || (mutation.mutation.range.length == 0 && lastMutation.mutation.range.length > 0) { diff --git a/Sources/CodeEditInputView/Utils/HorizontalEdgeInsets.swift b/Sources/CodeEditInputView/Utils/HorizontalEdgeInsets.swift new file mode 100644 index 000000000..133bf7b39 --- /dev/null +++ b/Sources/CodeEditInputView/Utils/HorizontalEdgeInsets.swift @@ -0,0 +1,32 @@ +// +// HorizontalEdgeInsets.swift +// +// +// Created by Khan Winter on 9/11/23. +// + +import Foundation + +public struct HorizontalEdgeInsets: Codable, Sendable, Equatable { + public var left: CGFloat + public var right: CGFloat + + public var horizontal: CGFloat { + left + right + } + + public init(left: CGFloat, right: CGFloat) { + self.left = left + self.right = right + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.left = try container.decode(CGFloat.self, forKey: .left) + self.right = try container.decode(CGFloat.self, forKey: .right) + } + + public static let zero: HorizontalEdgeInsets = { + HorizontalEdgeInsets(left: 0, right: 0) + }() +} diff --git a/Sources/CodeEditInputView/Utils/LineEnding.swift b/Sources/CodeEditInputView/Utils/LineEnding.swift index 6bd98b9a9..bb90889ee 100644 --- a/Sources/CodeEditInputView/Utils/LineEnding.swift +++ b/Sources/CodeEditInputView/Utils/LineEnding.swift @@ -9,11 +9,11 @@ import AppKit public enum LineEnding: String { /// The default unix `\n` character - case lf = "\n" + case lineFeed = "\n" /// MacOS line ending `\r` character - case cr = "\r" + case carriageReturn = "\r" /// Windows line ending sequence `\r\n` - case crlf = "\r\n" + case carriageReturnLineFeed = "\r\n" /// Initialize a line ending from a line string. /// - Parameter line: The line to use @@ -22,12 +22,12 @@ public enum LineEnding: String { guard let endChar = iterator.next() else { return nil } if endChar == "\n" { if let nextEndChar = iterator.next(), nextEndChar == "\r" { - self = .crlf + self = .carriageReturnLineFeed } else { - self = .lf + self = .lineFeed } } else if endChar == "\r" { - self = .cr + self = .carriageReturn } else { return nil } @@ -36,11 +36,14 @@ public 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. - public 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, - .crlf: 0 + .lineFeed: 0, + .carriageReturn: 0, + .carriageReturnLineFeed: 0 ] var shouldContinue = true var lineIterator = lineStorage.makeIterator() @@ -57,7 +60,7 @@ public enum LineEnding: String { } } - return histogram.max(by: { $0.value < $1.value })?.key ?? .lf + return histogram.max(by: { $0.value < $1.value })?.key ?? .lineFeed } public var length: Int { diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index a906f7f87..d62780a6f 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditTextView/CodeEditTextView.swift @@ -126,7 +126,6 @@ public struct CodeEditTextView: NSViewControllerRepresentable { // 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 // } diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift b/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift index 9463d488b..cb8edcad0 100644 --- a/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift +++ b/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift @@ -9,7 +9,7 @@ import AppKit import STTextView extension STTextViewController { -// public override func loadView() { +// override public func loadView() { // textView = CETextView() // // let scrollView = CEScrollView() @@ -66,7 +66,7 @@ extension STTextViewController { // self.setCursorPosition(self.cursorPosition.wrappedValue) // } - public override func viewDidLoad() { + override public func viewDidLoad() { super.viewDidLoad() NotificationCenter.default.addObserver(forName: NSWindow.didResizeNotification, @@ -117,7 +117,7 @@ extension STTextViewController { .store(in: &cancellables) } - public override func viewWillAppear() { + override public func viewWillAppear() { super.viewWillAppear() updateTextContainerWidthIfNeeded(true) } diff --git a/Sources/CodeEditTextView/Controller/STTextViewController.swift b/Sources/CodeEditTextView/Controller/STTextViewController.swift index 4c0ac129b..ad4a77390 100644 --- a/Sources/CodeEditTextView/Controller/STTextViewController.swift +++ b/Sources/CodeEditTextView/Controller/STTextViewController.swift @@ -253,7 +253,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt } } - public override func insertTab(_ sender: Any?) { + override public func insertTab(_ sender: Any?) { textView.insertText("\t", replacementRange: textView.selectedRange) } diff --git a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+TextInterface.swift b/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+TextInterface.swift index fb4b9f398..9a1e7368a 100644 --- a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+TextInterface.swift +++ b/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+TextInterface.swift @@ -51,7 +51,7 @@ extension STTextView: TextInterface { let delegate = self.delegate self.delegate = nil - textDidChange(nil) + didChangeText() self.delegate = delegate } } diff --git a/Sources/CodeEditTextView/Gutter/GutterView.swift b/Sources/CodeEditTextView/Gutter/GutterView.swift index 67df29021..141d39be7 100644 --- a/Sources/CodeEditTextView/Gutter/GutterView.swift +++ b/Sources/CodeEditTextView/Gutter/GutterView.swift @@ -101,7 +101,7 @@ public class GutterView: NSView { if originalMaxWidth != maxWidth { self.frame.size.width = maxWidth + edgeInsets.horizontal - textView.layoutManager.gutterWidth = maxWidth + edgeInsets.horizontal + textView.edgeInsets.left = maxWidth + edgeInsets.horizontal } } @@ -158,7 +158,7 @@ public class GutterView: NSView { context.restoreGState() } - public override func draw(_ dirtyRect: NSRect) { + override public 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 fafe0d69b..c7ee5ed2f 100644 --- a/Sources/CodeEditTextView/Highlighting/Highlighter.swift +++ b/Sources/CodeEditTextView/Highlighting/Highlighter.swift @@ -175,6 +175,7 @@ private extension Highlighter { self?.validSet.formUnion(IndexSet(integersIn: rangeToHighlight)) // Loop through each highlight and modify the textStorage accordingly. + textView.layoutManager.beginTransaction() textView.textStorage.beginEditing() // Create a set of indexes that were not highlighted. @@ -202,10 +203,7 @@ private extension Highlighter { } textView.textStorage.endEditing() - - // After applying edits to the text storage we need to invalidate the layout - // of the highlighted text. - textView.layoutManager.invalidateLayoutForRange(rangeToHighlight) + textView.layoutManager.endTransaction() } } diff --git a/Sources/CodeEditTextView/TextViewController.swift b/Sources/CodeEditTextView/TextViewController.swift index f51ab77dc..a16195863 100644 --- a/Sources/CodeEditTextView/TextViewController.swift +++ b/Sources/CodeEditTextView/TextViewController.swift @@ -82,7 +82,7 @@ public class TextViewController: NSViewController { } // swiftlint:disable:next function_body_length - public override func loadView() { + override public func loadView() { scrollView = NSScrollView() textView = TextView( string: string.wrappedValue, @@ -97,6 +97,7 @@ public class TextViewController: NSViewController { ) textView.postsFrameChangedNotifications = true textView.translatesAutoresizingMaskIntoConstraints = false + textView.edgeInsets = HorizontalEdgeInsets(left: 50, right: 75) scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.contentView.postsFrameChangedNotifications = true @@ -172,7 +173,7 @@ extension TextViewController: ThemeAttributesProviding { public func attributesFor(_ capture: CaptureName?) -> [NSAttributedString.Key: Any] { [ .font: font, - .foregroundColor: theme.colorFor(capture), + .foregroundColor: theme.colorFor(capture) // .paragraphStyle: paragraphStyle, // .kern: kern ] @@ -213,7 +214,7 @@ extension TextViewController { } extension TextViewController: TextViewDelegate { - public func textView(_ textView: TextView, didReplaceContents in: NSRange, with: String) { + public func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with: String) { gutterView.needsDisplay = true } } diff --git a/Sources/Common/Extensions/NSTextStorage+getLine.swift b/Sources/Common/Extensions/NSTextStorage+getLine.swift index 94b278370..cfc0bf9f4 100644 --- a/Sources/Common/Extensions/NSTextStorage+getLine.swift +++ b/Sources/Common/Extensions/NSTextStorage+getLine.swift @@ -1,5 +1,5 @@ // -// File.swift +// NSTextStorage+getLine.swift // // // Created by Khan Winter on 9/3/23. diff --git a/Sources/Common/Extensions/PixelAligned.swift b/Sources/Common/Extensions/PixelAligned.swift index d085e3040..80e501c99 100644 --- a/Sources/Common/Extensions/PixelAligned.swift +++ b/Sources/Common/Extensions/PixelAligned.swift @@ -1,5 +1,5 @@ // -// File.swift +// PixelAligned.swift // // // Created by Khan Winter on 9/10/23. From b0ac245c2b61896697e221fdae13d8ad86b0294a Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 11 Sep 2023 18:44:50 -0500 Subject: [PATCH 37/75] Fix Lint Bugs, Remove Warnings --- Package.swift | 18 ++++++---- ...lectionManager+SelectionManipulation.swift | 4 +-- .../TextLayoutLineStorageTests.swift | 34 ++++++------------- 3 files changed, 24 insertions(+), 32 deletions(-) diff --git a/Package.swift b/Package.swift index fd9f103a8..44b5a67f2 100644 --- a/Package.swift +++ b/Package.swift @@ -76,14 +76,20 @@ let package = Package( dependencies: [ "CodeEditTextView", "CodeEditLanguages", + ], + plugins: [ + .plugin(name: "SwiftLint", package: "SwiftLintPlugin") ] ), - .testTarget( - name: "CodeEditInputViewTests", - dependencies: [ - "CodeEditInputView", - ] - ), + .testTarget( + name: "CodeEditInputViewTests", + dependencies: [ + "CodeEditInputView", + ], + plugins: [ + .plugin(name: "SwiftLint", package: "SwiftLintPlugin") + ] + ), ] ) diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift index 35c282b89..fc443955c 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift @@ -119,9 +119,7 @@ public extension TextSelectionManager { 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 { + guard let line = layoutManager?.textLineForOffset(offset) else { return NSRange(location: offset, length: 0) } var rangeToDelete = NSRange(location: offset, length: 0) diff --git a/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift b/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift index e6b955047..c04f3e6c7 100644 --- a/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift +++ b/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift @@ -1,6 +1,8 @@ import XCTest @testable import CodeEditInputView +// swiftlint:disable function_body_length + fileprivate extension CGFloat { func approxEqual(_ value: CGFloat) -> Bool { return abs(self - value) < 0.05 @@ -13,8 +15,8 @@ final class TextLayoutLineStorageTests: XCTestCase { fileprivate func createBalancedTree() -> TextLineStorage { let tree = TextLineStorage() var data = [TextLineStorage.BuildItem]() - for i in 0..<15 { - data.append(.init(data: TextLine(), length: i + 1)) + for idx in 0..<15 { + data.append(.init(data: TextLine(), length: idx + 1)) } tree.build(from: data, estimatedLineHeight: 1.0) return tree @@ -58,20 +60,6 @@ final class TextLayoutLineStorageTests: XCTestCase { } } - func test_search() { - var tree = TextLineStorage() - tree.insert(line: TextLine(), atIndex: 0, length: 1, height: 1.0) - tree.insert(line: TextLine(), atIndex: 1, length: 2, height: 1.0) - tree.insert(line: TextLine(), atIndex: 0, length: 3, height: 1.0) - tree.insert(line: TextLine(), atIndex: 0, length: 4, height: 1.0) - tree.insert(line: TextLine(), atIndex: 7, length: 5, height: 1.0) - tree.insert(line: TextLine(), atIndex: 12, length: 6, height: 1.0) - printTree(tree) - for line in tree { - print(line.index) - } - } - func test_insert() throws { var tree = TextLineStorage() @@ -93,7 +81,6 @@ final class TextLayoutLineStorageTests: XCTestCase { tree.insert(line: TextLine(), atIndex: tree.length - 1, length: 1, height: 1.0) try assertTreeMetadataCorrect(tree) - // tree = createBalancedTree() tree.insert(line: TextLine(), atIndex: 45, length: 1, height: 1.0) try assertTreeMetadataCorrect(tree) @@ -199,7 +186,6 @@ final class TextLayoutLineStorageTests: XCTestCase { XCTAssert(tree.count == 14, "Tree length incorrect") try assertTreeMetadataCorrect(tree) - // Delete root tree = createBalancedTree() @@ -234,10 +220,10 @@ final class TextLayoutLineStorageTests: XCTestCase { func test_insertPerformance() { let tree = TextLineStorage() var lines: [TextLineStorage.BuildItem] = [] - for i in 0..<250_000 { + for idx in 0..<250_000 { lines.append(TextLineStorage.BuildItem( data: TextLine(), - length: i + 1 + length: idx + 1 )) } tree.build(from: lines, estimatedLineHeight: 1.0) @@ -267,18 +253,20 @@ final class TextLayoutLineStorageTests: XCTestCase { func test_iterationPerformance() { let tree = TextLineStorage() var lines: [TextLineStorage.BuildItem] = [] - for i in 0..<100_000 { + for idx in 0..<100_000 { lines.append(TextLineStorage.BuildItem( data: TextLine(), - length: i + 1 + length: idx + 1 )) } tree.build(from: lines, estimatedLineHeight: 1.0) measure { for line in tree { - let _ = line + _ = line } } } } + +// swiftlint:enable function_body_length From a3aad973bae21a57246e3edf58eae9e1598ae2eb Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 11 Sep 2023 19:12:08 -0500 Subject: [PATCH 38/75] Add Some TextSelectionManager Tests --- ...lectionManager+SelectionManipulation.swift | 23 ++++- .../TextSelectionManagerTests.swift | 94 +++++++++++++++++++ 2 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 Tests/CodeEditInputViewTests/TextSelectionManagerTests.swift diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift index fc443955c..803f69227 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift @@ -26,9 +26,19 @@ public extension TextSelectionManager { switch direction { case .backward: guard offset > 0 else { return NSRange(location: offset, length: 0) } // Can't go backwards beyond 0 - return extendSelection(from: offset, destination: destination, delta: -1) + return extendSelection( + from: offset, + destination: destination, + delta: -1, + decomposeCharacters: decomposeCharacters + ) case .forward: - return extendSelection(from: offset, destination: destination, delta: 1) + return extendSelection( + from: offset, + destination: destination, + delta: 1, + decomposeCharacters: decomposeCharacters + ) case .up: // TODO: up return NSRange(location: offset, length: 0) case .down: // TODO: down @@ -97,6 +107,12 @@ public extension TextSelectionManager { decomposeCharacters: Bool ) -> NSRange { let range = delta > 0 ? NSRange(location: offset, length: 1) : NSRange(location: offset - 1, length: 1) + if delta > 0 && offset == string.length - 1 { + return NSRange(location: offset, length: 0) + } else if delta < 0 && offset == 0 { + return NSRange(location: 0, length: 0) + } + if decomposeCharacters { return range } else { @@ -119,9 +135,6 @@ public extension TextSelectionManager { if delta < 0 { enumerationOptions.formUnion(.reverse) } - guard let line = layoutManager?.textLineForOffset(offset) else { - return NSRange(location: offset, length: 0) - } var rangeToDelete = NSRange(location: offset, length: 0) var hasFoundValidWordChar = false diff --git a/Tests/CodeEditInputViewTests/TextSelectionManagerTests.swift b/Tests/CodeEditInputViewTests/TextSelectionManagerTests.swift new file mode 100644 index 000000000..df09bc5a7 --- /dev/null +++ b/Tests/CodeEditInputViewTests/TextSelectionManagerTests.swift @@ -0,0 +1,94 @@ +import XCTest +@testable import CodeEditInputView + +final class TextSelectionManagerTests: XCTestCase { + var textStorage: NSTextStorage! + var layoutManager: TextLayoutManager! + + override func setUp() { + textStorage = NSTextStorage(string: "Loren Ipsum 💯") + layoutManager = TextLayoutManager( + textStorage: textStorage, + typingAttributes: [:], + lineHeightMultiplier: 1.0, + wrapLines: false, + textView: NSView(), + delegate: nil + ) + } + + func selectionManager() -> TextSelectionManager { + TextSelectionManager( + layoutManager: layoutManager, + textStorage: textStorage, + layoutView: nil, + delegate: nil + ) + } + + func test_updateSelectionLeft() { + let selectionManager = selectionManager() + let locations = [2, 0, 14, 14] + let expectedRanges = [(1, 1), (0, 0), (12, 2), (13, 1)] + let decomposeCharacters = [false, false, false, true] + + for idx in locations.indices { + let range = selectionManager.rangeOfSelection( + from: locations[idx], + direction: .backward, + destination: .character, + decomposeCharacters: decomposeCharacters[idx] + ) + + XCTAssert( + range.location == expectedRanges[idx].0, + "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" + ) + XCTAssert( + range.length == expectedRanges[idx].1, + "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" + ) + } + } + + func test_updateSelectionRight() { + let selectionManager = selectionManager() + let locations = [2, 0, 13, 12, 12] + let expectedRanges = [(2, 1), (0, 1), (13, 0), (12, 2), (12, 1)] + let decomposeCharacters = [false, false, false, false, true] + + for idx in locations.indices { + let range = selectionManager.rangeOfSelection( + from: locations[idx], + direction: .forward, + destination: .character, + decomposeCharacters: decomposeCharacters[idx] + ) + + XCTAssert( + range.location == expectedRanges[idx].0, + "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" + ) + XCTAssert( + range.length == expectedRanges[idx].1, + "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" + ) + } + } + + func test_updateSelectionLeftWord() { + // TODO + } + + func test_updateSelectionRightWord() { + // TODO + } + + func test_updateSelectionLeftLine() { + // TODO + } + + func test_updateSelectionRightLine() { + // TODO + } +} From 1e42a4cf1feb77257f7549581b291df0943cd6c5 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 13 Sep 2023 00:34:46 -0500 Subject: [PATCH 39/75] Add `scrollSelectionToVisible`, Work on Arrow Keys --- .../TextLayoutManager+Public.swift | 148 ++++++++++++++++++ .../TextLayoutManager/TextLayoutManager.swift | 96 +----------- ...lectionManager+SelectionManipulation.swift | 126 ++++++++++++++- .../TextSelectionManager.swift | 2 +- .../TextView/TextView+Move.swift | 93 ++++++++++- .../TextView/TextView+NSTextInput.swift | 20 +-- .../CodeEditInputView/TextView/TextView.swift | 19 +++ 7 files changed, 388 insertions(+), 116 deletions(-) create mode 100644 Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift new file mode 100644 index 000000000..75adc3bc3 --- /dev/null +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift @@ -0,0 +1,148 @@ +// +// TextLayoutManager+Public.swift +// +// +// Created by Khan Winter on 9/13/23. +// + +import AppKit + +extension TextLayoutManager { + public func estimatedHeight() -> CGFloat { + lineStorage.height + } + + public func estimatedWidth() -> CGFloat { + maxLineWidth + } + + public func textLineForPosition(_ posY: CGFloat) -> TextLineStorage.TextLinePosition? { + 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( + atPosition: point.y - position.yPos + ) else { + return nil + } + let fragment = fragmentPosition.data + + if fragment.width < point.x - edgeInsets.left { + 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 - edgeInsets.left, y: fragment.height/2) + ) + return position.range.location + fragmentIndex + } + } + + /// Find a position for the character at a given offset. + /// 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 rectForOffset(_ offset: Int) -> CGRect? { + guard let linePosition = lineStorage.getLine(atIndex: offset) else { + return nil + } + if linePosition.data.lineFragments.isEmpty { + let newHeight = ensureLayoutFor(position: linePosition) + if linePosition.height != newHeight { + delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) + } + } + + guard 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 minXPos = CTLineGetOffsetForStringIndex( + fragmentPosition.data.ctLine, + offset - linePosition.range.location, + nil + ) + let maxXPos = CTLineGetOffsetForStringIndex( + fragmentPosition.data.ctLine, + offset - linePosition.range.location + charLengthAtOffset, + nil + ) + + return CGRect( + x: minXPos + edgeInsets.left, + y: linePosition.yPos + fragmentPosition.yPos, + width: (maxXPos - minXPos) + edgeInsets.left, + height: fragmentPosition.data.scaledHeight + ) + } + + /// Forces layout calculation for all lines up to and including the given offset. + /// - Parameter offset: The offset to ensure layout until. + public func ensureLayoutUntil(_ offset: Int) { + guard let linePosition = lineStorage.getLine(atIndex: offset), + let visibleRect = delegate?.visibleRect, + visibleRect.maxY < linePosition.yPos + linePosition.height, + let startingLinePosition = lineStorage.getLine(atPosition: visibleRect.minY) + else { + return + } + let originalHeight = lineStorage.height + + for linePosition in lineStorage.linesInRange( + NSRange( + location: startingLinePosition.range.location, + length: linePosition.range.max - startingLinePosition.range.location + ) + ) { + let height = ensureLayoutFor(position: linePosition) + if height != linePosition.height { + lineStorage.update( + atIndex: linePosition.range.location, + delta: 0, + deltaHeight: height - linePosition.height + ) + } + } + + if originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height { + delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) + } + } + + /// Forces layout calculation for all lines up to and including the given offset. + /// - Parameter offset: The offset to ensure layout until. + private func ensureLayoutFor(position: TextLineStorage.TextLinePosition) -> CGFloat { + position.data.prepareForDisplay( + maxWidth: maxLineWidth, + lineHeightMultiplier: lineHeightMultiplier, + range: position.range, + stringRef: textStorage + ) + var height: CGFloat = 0 + for fragmentPosition in position.data.lineFragments { + height += fragmentPosition.data.scaledHeight + } + return height + } +} diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift index dcc5165d5..7507f1380 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift @@ -49,13 +49,17 @@ public class TextLayoutManager: NSObject { private var needsLayout: Bool = false private(set) public var isInTransaction: Bool = false - weak private var layoutView: NSView? + weak internal var layoutView: NSView? - private var maxLineWidth: CGFloat = 0 { + internal var maxLineWidth: CGFloat = 0 { didSet { delegate?.layoutManagerMaxWidthDidChange(newWidth: maxLineWidth) } } + private var maxLineLayoutWidth: CGFloat { + wrapLines ? (delegate?.textViewSize().width ?? .greatestFiniteMagnitude) - edgeInsets.horizontal + : .greatestFiniteMagnitude + } // MARK: - Init @@ -110,87 +114,6 @@ public class TextLayoutManager: NSObject { return (ascent + descent + leading) * lineHeightMultiplier } - // MARK: - Public Methods - - public func estimatedHeight() -> CGFloat { - lineStorage.height - } - - public func estimatedWidth() -> CGFloat { - maxLineWidth - } - - public func textLineForPosition(_ posY: CGFloat) -> TextLineStorage.TextLinePosition? { - 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( - atPosition: point.y - position.yPos - ) else { - return nil - } - let fragment = fragmentPosition.data - - if fragment.width < point.x - edgeInsets.left { - 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 - edgeInsets.left, y: fragment.height/2) - ) - return position.range.location + fragmentIndex - } - } - - /// Find a position for the character at a given offset. - /// 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 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 minXPos = CTLineGetOffsetForStringIndex( - fragmentPosition.data.ctLine, - offset - linePosition.range.location, - nil - ) - let maxXPos = CTLineGetOffsetForStringIndex( - fragmentPosition.data.ctLine, - offset - linePosition.range.location + charLengthAtOffset, - nil - ) - - return CGRect( - x: minXPos + edgeInsets.left, - y: linePosition.yPos + fragmentPosition.yPos, - width: (maxXPos - minXPos) + edgeInsets.left, - height: fragmentPosition.data.scaledHeight - ) - } - // MARK: - Invalidation /// Invalidates layout for the given rect. @@ -240,9 +163,6 @@ public class TextLayoutManager: NSObject { let originalHeight = lineStorage.height var usedFragmentIDs = Set() var forceLayout: Bool = needsLayout - let maxWidth: CGFloat = wrapLines - ? (delegate?.textViewSize().width ?? .greatestFiniteMagnitude) - edgeInsets.horizontal - : .greatestFiniteMagnitude var newVisibleLines: Set = [] var yContentAdjustment: CGFloat = 0 @@ -252,13 +172,13 @@ public class TextLayoutManager: NSObject { guard linePosition.yPos < maxY else { break } if forceLayout - || linePosition.data.needsLayout(maxWidth: maxWidth) + || linePosition.data.needsLayout(maxWidth: maxLineLayoutWidth) || !visibleLineIds.contains(linePosition.data.id) { let lineSize = layoutLine( linePosition, minY: linePosition.yPos, maxY: maxY, - maxWidth: maxWidth, + maxWidth: maxLineLayoutWidth, laidOutFragmentIDs: &usedFragmentIDs ) if lineSize.height != linePosition.height { diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift index 803f69227..830db1117 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift @@ -16,12 +16,14 @@ public extension TextSelectionManager { /// - 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. + /// - suggestedXPos: The suggested x position to stick to. /// - Returns: A range of a new selection based on the direction and destination. func rangeOfSelection( from offset: Int, direction: Direction, destination: Destination, - decomposeCharacters: Bool = false + decomposeCharacters: Bool = false, + suggestedXPos: CGFloat? = nil ) -> NSRange { switch direction { case .backward: @@ -39,10 +41,20 @@ public extension TextSelectionManager { delta: 1, decomposeCharacters: decomposeCharacters ) - case .up: // TODO: up - return NSRange(location: offset, length: 0) - case .down: // TODO: down - return NSRange(location: offset, length: 0) + case .up: + return extendSelectionVertical( + from: offset, + destination: destination, + up: true, + suggestedXPos: suggestedXPos + ) + case .down: + return extendSelectionVertical( + from: offset, + destination: destination, + up: false, + suggestedXPos: suggestedXPos + ) } } @@ -74,10 +86,8 @@ public extension TextSelectionManager { ) case .word: return extendSelectionWord(string: string, from: offset, delta: delta) - case .line: + case .line, .container: 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) @@ -231,12 +241,112 @@ public extension TextSelectionManager { return foundRange.length == 0 ? originalFoundRange : foundRange } + // MARK: - Vertical Methods + + /// Extends a selection from the given offset vertically to the destination. + /// - Parameters: + /// - offset: The offset to extend from. + /// - destination: The destination to extend to. + /// - up: Set to true if extending up. + /// - suggestedXPos: The suggested x position to stick to. + /// - Returns: The range of the extended selection. + private func extendSelectionVertical( + from offset: Int, + destination: Destination, + up: Bool, + suggestedXPos: CGFloat? + ) -> NSRange { + switch destination { + case .character: + return extendSelectionVerticalCharacter(from: offset, up: up, suggestedXPos: suggestedXPos) + case .word, .line: + return extendSelectionVerticalLine(from: offset, up: up) + case .container: + return extendSelectionContainer(from: offset, delta: up ? 1 : -1) + case .document: + if up { + return NSRange(location: 0, length: offset) + } else { + return NSRange(location: offset, length: (textStorage?.length ?? 0) - offset - 1) + } + } + } + + /// Extends the selection to the nearest character vertically. + /// - Parameters: + /// - offset: The offset to extend from. + /// - up: Set to true if extending up. + /// - suggestedXPos: The suggested x position to stick to. + /// - Returns: The range of the extended selection. + private func extendSelectionVerticalCharacter( + from offset: Int, + up: Bool, + suggestedXPos: CGFloat? + ) -> NSRange { + guard let point = layoutManager?.rectForOffset(offset)?.origin, + let newOffset = layoutManager?.textOffsetAtPoint( + CGPoint( + x: suggestedXPos == nil ? point.x : suggestedXPos! + (layoutManager?.edgeInsets.left ?? 0), + y: point.y - (layoutManager?.estimateLineHeight() ?? 2.0)/2 * (up ? 1 : -3) + ) + ) else { + return NSRange(location: offset, length: 0) + } + + return NSRange( + location: up ? newOffset : offset, + length: up ? offset - newOffset : newOffset - offset + ) + } + + /// Extends the selection to the nearest line vertically. + /// + /// If moving up and the offset is in the middle of the line, it first extends it to the beginning of the line. + /// On the second call, it will extend it to the beginning of the previous line. When moving down, the + /// same thing will happen in the opposite direction. + /// + /// - Parameters: + /// - offset: The offset to extend from. + /// - up: Set to true if extending up. + /// - suggestedXPos: The suggested x position to stick to. + /// - Returns: The range of the extended selection. + private func extendSelectionVerticalLine( + from offset: Int, + up: Bool + ) -> NSRange { + // Important distinction here, when moving up/down on a line and in the middle of the line, we move to the + // beginning/end of the *entire* line, not the line fragment. + guard let line = layoutManager?.textLineForOffset(offset) else { + return NSRange(location: offset, length: 0) + } + if up && line.range.location != offset { + return NSRange(location: line.range.location, length: offset - line.index) + } else if !up && line.range.max - (layoutManager?.detectedLineEnding.length ?? 0) != offset { + return NSRange( + location: offset, + length: line.range.max - offset - (layoutManager?.detectedLineEnding.length ?? 0) + ) + } else { + let nextQueryIndex = up ? max(line.range.location - 1, 0) : min(line.range.max, (textStorage?.length ?? 0)) + guard let nextLine = layoutManager?.textLineForOffset(nextQueryIndex) else { + return NSRange(location: offset, length: 0) + } + return NSRange( + location: up ? nextLine.range.location : offset, + length: up + ? offset - nextLine.range.location + : nextLine.range.max - offset - (layoutManager?.detectedLineEnding.length ?? 0) + ) + } + } + /// 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 { + // TODO: Needs to force layout for the rect being moved by. guard let layoutView, let endOffset = layoutManager?.textOffsetAtPoint( CGPoint( x: delta > 0 ? layoutView.frame.maxX : layoutView.frame.minX, diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift index 569c91efa..c6f7f8f75 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift @@ -30,6 +30,7 @@ public class TextSelectionManager: NSObject { public class TextSelection { public var range: NSRange internal weak var view: CursorView? + public var suggestedXPos: CGFloat? init(range: NSRange, view: CursorView? = nil) { self.range = range @@ -110,7 +111,6 @@ public class TextSelectionManager: NSObject { let lineFragment = layoutManager? .textLineForOffset(textSelection.range.location)? .data - .typesetter .lineFragments .first let cursorOrigin = (layoutManager?.rectForOffset(textSelection.range.location) ?? .zero).origin diff --git a/Sources/CodeEditInputView/TextView/TextView+Move.swift b/Sources/CodeEditInputView/TextView/TextView+Move.swift index fd5f64f43..cb299fceb 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Move.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Move.swift @@ -8,22 +8,31 @@ import Foundation extension TextView { + // TODO: Move up/down character need to remember the xPos they started at. + // Comment for TODO. When moving up/down users expect to move across lines of different lengths while keeping their + // cursor as close as possible to the original x position. This needs to be implemented. + + /// Moves the cursors up one character. override public func moveUp(_ sender: Any?) { moveSelections(direction: .up, destination: .character) } + /// Moves the cursors up one character extending the current selection. override public func moveUpAndModifySelection(_ sender: Any?) { moveSelections(direction: .up, destination: .character, modifySelection: true) } + /// Moves the cursors down one character. override public func moveDown(_ sender: Any?) { moveSelections(direction: .down, destination: .character) } + /// Moves the cursors down one character extending the current selection. override public func moveDownAndModifySelection(_ sender: Any?) { moveSelections(direction: .down, destination: .character, modifySelection: true) } + /// Moves the cursors left one character. override public func moveLeft(_ sender: Any?) { selectionManager.textSelections.forEach { selection in if selection.range.isEmpty { @@ -34,13 +43,16 @@ extension TextView { } } selectionManager.updateSelectionViews() + scrollSelectionToVisible() setNeedsDisplay() } + /// Moves the cursors left one character extending the current selection. override public func moveLeftAndModifySelection(_ sender: Any?) { moveSelections(direction: .backward, destination: .character, modifySelection: true) } + /// Moves the cursors right one character. override public func moveRight(_ sender: Any?) { selectionManager.textSelections.forEach { selection in if selection.range.isEmpty { @@ -50,45 +62,105 @@ extension TextView { } } selectionManager.updateSelectionViews() + scrollSelectionToVisible() setNeedsDisplay() } + /// Moves the cursors right one character extending the current selection. override public func moveRightAndModifySelection(_ sender: Any?) { moveSelections(direction: .forward, destination: .character, modifySelection: true) } + /// Moves the cursors left one word. override public func moveWordLeft(_ sender: Any?) { moveSelections(direction: .backward, destination: .word) } + /// Moves the cursors left one word extending the current selection. override public func moveWordLeftAndModifySelection(_ sender: Any?) { moveSelections(direction: .backward, destination: .word, modifySelection: true) } + /// Moves the cursors right one word. override public func moveWordRight(_ sender: Any?) { moveSelections(direction: .forward, destination: .word) } + /// Moves the cursors right one word extending the current selection. override public func moveWordRightAndModifySelection(_ sender: Any?) { moveSelections(direction: .forward, destination: .word, modifySelection: true) } + /// Moves the cursors left to the end of the line. override public func moveToLeftEndOfLine(_ sender: Any?) { moveSelections(direction: .backward, destination: .line) } + /// Moves the cursors left to the end of the line extending the current selection. override public func moveToLeftEndOfLineAndModifySelection(_ sender: Any?) { moveSelections(direction: .backward, destination: .line, modifySelection: true) } + /// Moves the cursors right to the end of the line. override public func moveToRightEndOfLine(_ sender: Any?) { moveSelections(direction: .forward, destination: .line) } + /// Moves the cursors right to the end of the line extending the current selection. override public func moveToRightEndOfLineAndModifySelection(_ sender: Any?) { moveSelections(direction: .forward, destination: .line, modifySelection: true) } + /// Moves the cursors to the beginning of the line, if pressed again selects the next line up. + override public func moveToBeginningOfParagraph(_ sender: Any?) { + moveSelections(direction: .up, destination: .line) + } + + /// Moves the cursors to the beginning of the line, if pressed again selects the next line up extending the current + /// selection. + override public func moveToBeginningOfParagraphAndModifySelection(_ sender: Any?) { + moveSelections(direction: .up, destination: .line, modifySelection: true) + } + + /// Moves the cursors to the end of the line, if pressed again selects the next line up. + override public func moveToEndOfParagraph(_ sender: Any?) { + moveSelections(direction: .down, destination: .line) + } + + /// Moves the cursors to the end of the line, if pressed again selects the next line up extending the current + /// selection. + override public func moveToEndOfParagraphAndModifySelection(_ sender: Any?) { + moveSelections(direction: .down, destination: .line, modifySelection: true) + } + + /// Moves the cursors to the beginning of the document. + override public func moveToBeginningOfDocument(_ sender: Any?) { + moveSelections(direction: .up, destination: .document) + } + + /// Moves the cursors to the beginning of the document extending the current selection. + override public func moveToBeginningOfDocumentAndModifySelection(_ sender: Any?) { + moveSelections(direction: .up, destination: .document, modifySelection: true) + } + + /// Moves the cursors to the end of the document. + override public func moveToEndOfDocument(_ sender: Any?) { + moveSelections(direction: .down, destination: .document) + } + + /// Moves the cursors to the end of the document extending the current selection. + override public func moveToEndOfDocumentAndModifySelection(_ sender: Any?) { + moveSelections(direction: .down, destination: .document, modifySelection: true) + } + + /// Moves all selections, determined by the direction and destination provided. + /// + /// Also handles updating the selection views and marks the view as needing display. + /// + /// - Parameters: + /// - direction: The direction to modify all selections. + /// - destination: The destination to move the selections by. + /// - modifySelection: Set to `true` to modify the selections instead of replacing it. fileprivate func moveSelections( direction: TextSelectionManager.Direction, destination: TextSelectionManager.Destination, @@ -103,9 +175,16 @@ extension TextView { ) } selectionManager.updateSelectionViews() + scrollSelectionToVisible() setNeedsDisplay() } + /// Moves a single selection determined by the direction and destination provided. + /// - Parameters: + /// - selection: The selection to modify. + /// - direction: The direction to move in. + /// - destination: The destination of the move. + /// - modifySelection: Set to `true` to modify the selection instead of replacing it. fileprivate func moveSelection( selection: TextSelectionManager.TextSelection, direction: TextSelectionManager.Direction, @@ -115,15 +194,25 @@ extension TextView { let range = selectionManager.rangeOfSelection( from: direction == .forward ? selection.range.max : selection.range.location, direction: direction, - destination: destination + destination: destination, + suggestedXPos: selection.suggestedXPos ) + print(selection.suggestedXPos, layoutManager?.rectForOffset(range.location)?.minX) if modifySelection { selection.range.formUnion(range) } else { switch direction { - case .up, .down, .backward: + case .up: + selection.suggestedXPos = selection.suggestedXPos ?? layoutManager?.rectForOffset(range.location)?.minX + selection.range = NSRange(location: range.location, length: 0) + case .down: + selection.suggestedXPos = selection.suggestedXPos ?? layoutManager?.rectForOffset(range.max)?.minX + selection.range = NSRange(location: range.max, length: 0) + case .backward: + selection.suggestedXPos = nil selection.range = NSRange(location: range.location, length: 0) case .forward: + selection.suggestedXPos = nil selection.range = NSRange(location: range.max, length: 0) } } diff --git a/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift b/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift index 22deb6fcc..5f0875d10 100644 --- a/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift +++ b/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift @@ -63,26 +63,12 @@ extension TextView: NSTextInputClient { } else { replaceCharacters(in: replacementRange, with: insertString) } + + selectionManager.textSelections.forEach { $0.suggestedXPos = nil } } override public func insertText(_ insertString: Any) { - var string: String - switch insertString { - case let insertString as NSString: - string = insertString as String - case let insertString as NSAttributedString: - string = insertString.string - default: - string = "" - assertionFailure("\(#function) called with invalid string type. Expected String or NSAttributedString.") - } - - if LineEnding(rawValue: string) == .carriageReturn - && layoutManager.detectedLineEnding == .carriageReturnLineFeed { - string = LineEnding.carriageReturnLineFeed.rawValue - } - - replaceCharacters(in: selectionManager.textSelections.map(\.range), with: string) + insertText(insertString, replacementRange: NSRange(location: NSNotFound, length: 0)) } // MARK: - Marked Text diff --git a/Sources/CodeEditInputView/TextView/TextView.swift b/Sources/CodeEditInputView/TextView/TextView.swift index 8db899965..a4ab0d061 100644 --- a/Sources/CodeEditInputView/TextView/TextView.swift +++ b/Sources/CodeEditInputView/TextView/TextView.swift @@ -331,6 +331,25 @@ public class TextView: NSView, NSTextContent { return didUpdate } + /// Scrolls the upmost selection to the visible rect if `scrollView` is not `nil`. + public func scrollSelectionToVisible() { + guard let scrollView, + let selection = selectionManager + .textSelections + .sorted(by: { $0.view?.frame.minY ?? 0.0 < $1.view?.frame.minY ?? 0.0 }) + .first + else { + return + } + var lastFrame: CGRect = .zero + while lastFrame != selection.view?.frame, let view = selection.view { + lastFrame = view.frame + layoutManager.layoutLines() + selectionManager.updateSelectionViews() + } + scrollView.contentView.scrollToVisible(lastFrame) + } + deinit { layoutManager = nil selectionManager = nil From 882fa43d453ff1b1447f06c34bc51092d3f3c326 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 15 Sep 2023 13:21:13 -0500 Subject: [PATCH 40/75] Finish Keyboard Navigation --- .../TextLayoutManager+Public.swift | 10 +-- .../TextLayoutManager/TextLayoutManager.swift | 12 +++- ...lectionManager+SelectionManipulation.swift | 2 +- .../TextSelectionManager.swift | 12 +++- .../TextView/TextView+Move.swift | 32 ++++++---- .../TextView/TextView+Setup.swift | 32 ++++++++++ .../TextView+TextLayoutManagerDelegate.swift | 39 ++++++++++++ .../CodeEditInputView/TextView/TextView.swift | 63 +------------------ .../CodeEditTextView/TextViewController.swift | 2 +- 9 files changed, 120 insertions(+), 84 deletions(-) create mode 100644 Sources/CodeEditInputView/TextView/TextView+Setup.swift create mode 100644 Sources/CodeEditInputView/TextView/TextView+TextLayoutManagerDelegate.swift diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift index 75adc3bc3..8523c5a88 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift @@ -33,7 +33,9 @@ extension TextLayoutManager { } let fragment = fragmentPosition.data - if fragment.width < point.x - edgeInsets.left { + if fragment.width == 0 { + return position.range.location + fragmentPosition.range.location + } else if fragment.width < point.x - edgeInsets.left { let fragmentRange = CTLineGetStringRange(fragment.ctLine) // Return eol return position.range.location + fragmentRange.location + fragmentRange.length - ( @@ -76,16 +78,16 @@ extension TextLayoutManager { // 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 realRange = (textStorage.string as NSString).rangeOfComposedCharacterSequence(at: offset) let minXPos = CTLineGetOffsetForStringIndex( fragmentPosition.data.ctLine, - offset - linePosition.range.location, + realRange.location - linePosition.range.location - fragmentPosition.range.location, nil ) let maxXPos = CTLineGetOffsetForStringIndex( fragmentPosition.data.ctLine, - offset - linePosition.range.location + charLengthAtOffset, + realRange.max - linePosition.range.location - fragmentPosition.range.location, nil ) diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift index 7507f1380..8e80fac10 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift @@ -30,6 +30,7 @@ public class TextLayoutManager: NSObject { /// The edge insets to inset all text layout with. public var edgeInsets: HorizontalEdgeInsets = .zero { didSet { + delegate?.layoutManagerMaxWidthDidChange(newWidth: maxLineWidth + edgeInsets.horizontal) setNeedsLayout() } } @@ -53,7 +54,7 @@ public class TextLayoutManager: NSObject { internal var maxLineWidth: CGFloat = 0 { didSet { - delegate?.layoutManagerMaxWidthDidChange(newWidth: maxLineWidth) + delegate?.layoutManagerMaxWidthDidChange(newWidth: maxLineWidth + edgeInsets.horizontal) } } private var maxLineLayoutWidth: CGFloat { @@ -165,6 +166,7 @@ public class TextLayoutManager: NSObject { var forceLayout: Bool = needsLayout var newVisibleLines: Set = [] var yContentAdjustment: CGFloat = 0 + var maxFoundLineWidth = maxLineWidth // Layout all lines for linePosition in lineStorage.linesStartingAt(minY, until: maxY) { @@ -195,8 +197,8 @@ public class TextLayoutManager: NSObject { yContentAdjustment += lineSize.height - linePosition.height } } - if maxLineWidth < lineSize.width + edgeInsets.horizontal { - maxLineWidth = lineSize.width + if maxFoundLineWidth < lineSize.width { + maxFoundLineWidth = lineSize.width } } else { // Make sure the used fragment views aren't dequeued. @@ -215,6 +217,10 @@ public class TextLayoutManager: NSObject { delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) } + if maxFoundLineWidth > maxLineWidth { + maxLineWidth = maxFoundLineWidth + } + if yContentAdjustment != 0 { delegate?.layoutManagerYAdjustment(yContentAdjustment) } diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift index 830db1117..cb8fa72c3 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift @@ -286,7 +286,7 @@ public extension TextSelectionManager { guard let point = layoutManager?.rectForOffset(offset)?.origin, let newOffset = layoutManager?.textOffsetAtPoint( CGPoint( - x: suggestedXPos == nil ? point.x : suggestedXPos! + (layoutManager?.edgeInsets.left ?? 0), + x: suggestedXPos == nil ? point.x : suggestedXPos!, y: point.y - (layoutManager?.estimateLineHeight() ?? 2.0)/2 * (up ? 1 : -3) ) ) else { diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift index c6f7f8f75..01ae68ccc 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift @@ -30,7 +30,7 @@ public class TextSelectionManager: NSObject { public class TextSelection { public var range: NSRange internal weak var view: CursorView? - public var suggestedXPos: CGFloat? + internal var suggestedXPos: CGFloat? init(range: NSRange, view: CursorView? = nil) { self.range = range @@ -92,13 +92,19 @@ public class TextSelectionManager: NSObject { public func setSelectedRange(_ range: NSRange) { textSelections.forEach { $0.view?.removeFromSuperview() } - textSelections = [TextSelection(range: range)] + let selection = TextSelection(range: range) + selection.suggestedXPos = layoutManager?.rectForOffset(range.location)?.minX + textSelections = [selection] updateSelectionViews() } public func setSelectedRanges(_ ranges: [NSRange]) { textSelections.forEach { $0.view?.removeFromSuperview() } - textSelections = ranges.map { TextSelection(range: $0) } + textSelections = ranges.map { + let selection = TextSelection(range: $0) + selection.suggestedXPos = layoutManager?.rectForOffset($0.location)?.minX + return selection + } updateSelectionViews() } diff --git a/Sources/CodeEditInputView/TextView/TextView+Move.swift b/Sources/CodeEditInputView/TextView/TextView+Move.swift index cb299fceb..d89e259c1 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Move.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Move.swift @@ -197,22 +197,32 @@ extension TextView { destination: destination, suggestedXPos: selection.suggestedXPos ) - print(selection.suggestedXPos, layoutManager?.rectForOffset(range.location)?.minX) + switch direction { + case .up: + if destination != .line { + selection.suggestedXPos = selection.suggestedXPos ?? layoutManager?.rectForOffset(range.location)?.minX + } else { + selection.suggestedXPos = nil + } + case .down: + if destination == .line { + selection.suggestedXPos = layoutManager?.rectForOffset(range.max)?.minX + } else { + selection.suggestedXPos = selection.suggestedXPos ?? layoutManager?.rectForOffset(range.max)?.minX + } + case .forward: + selection.suggestedXPos = layoutManager?.rectForOffset(range.location)?.minX + case .backward: + selection.suggestedXPos = layoutManager?.rectForOffset(range.location)?.minX + } + if modifySelection { selection.range.formUnion(range) } else { switch direction { - case .up: - selection.suggestedXPos = selection.suggestedXPos ?? layoutManager?.rectForOffset(range.location)?.minX + case .up, .backward: selection.range = NSRange(location: range.location, length: 0) - case .down: - selection.suggestedXPos = selection.suggestedXPos ?? layoutManager?.rectForOffset(range.max)?.minX - selection.range = NSRange(location: range.max, length: 0) - case .backward: - selection.suggestedXPos = nil - selection.range = NSRange(location: range.location, length: 0) - case .forward: - selection.suggestedXPos = nil + case .down, .forward: selection.range = NSRange(location: range.max, length: 0) } } diff --git a/Sources/CodeEditInputView/TextView/TextView+Setup.swift b/Sources/CodeEditInputView/TextView/TextView+Setup.swift new file mode 100644 index 000000000..f833d41d8 --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+Setup.swift @@ -0,0 +1,32 @@ +// +// TextView+Setup.swift +// +// +// Created by Khan Winter on 9/15/23. +// + +import AppKit + +extension TextView { + internal func setUpLayoutManager() { + layoutManager = TextLayoutManager( + textStorage: textStorage, + typingAttributes: [ + .font: font + ], + lineHeightMultiplier: lineHeight, + wrapLines: wrapLines, + textView: self, // TODO: This is an odd syntax... consider reworking this + delegate: self + ) + } + + internal func setUpSelectionManager() { + selectionManager = TextSelectionManager( + layoutManager: layoutManager, + textStorage: textStorage, + layoutView: self, // TODO: This is an odd syntax... consider reworking this + delegate: self + ) + } +} diff --git a/Sources/CodeEditInputView/TextView/TextView+TextLayoutManagerDelegate.swift b/Sources/CodeEditInputView/TextView/TextView+TextLayoutManagerDelegate.swift new file mode 100644 index 000000000..3b15bbc23 --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+TextLayoutManagerDelegate.swift @@ -0,0 +1,39 @@ +// +// TextView+TextLayoutManagerDelegate.swift +// +// +// Created by Khan Winter on 9/15/23. +// + +import AppKit + +extension TextView: TextLayoutManagerDelegate { + public func layoutManagerHeightDidUpdate(newHeight: CGFloat) { + updateFrameIfNeeded() + } + + public func layoutManagerMaxWidthDidChange(newWidth: CGFloat) { + updateFrameIfNeeded() + } + + public func textViewSize() -> 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) + } + } + + public func textLayoutSetNeedsDisplay() { + needsDisplay = true + needsLayout = true + } + + public func layoutManagerYAdjustment(_ yAdjustment: CGFloat) { + var point = scrollView?.documentVisibleRect.origin ?? .zero + point.y += yAdjustment + scrollView?.documentView?.scroll(point) + } +} diff --git a/Sources/CodeEditInputView/TextView/TextView.swift b/Sources/CodeEditInputView/TextView/TextView.swift index a4ab0d061..64a33db78 100644 --- a/Sources/CodeEditInputView/TextView/TextView.swift +++ b/Sources/CodeEditInputView/TextView/TextView.swift @@ -47,7 +47,6 @@ public class TextView: NSView, NSTextContent { didSet { layoutManager.edgeInsets = edgeInsets selectionManager.updateSelectionViews() - setNeedsDisplay() } } @@ -146,28 +145,6 @@ public class TextView: NSView, NSTextContent { fatalError("init(coder:) has not been implemented") } - private func setUpLayoutManager() { - layoutManager = TextLayoutManager( - textStorage: textStorage, - typingAttributes: [ - .font: font - ], - lineHeightMultiplier: lineHeight, - wrapLines: wrapLines, - textView: self, // TODO: This is an odd syntax... consider reworking this - delegate: self - ) - } - - private func setUpSelectionManager() { - selectionManager = TextSelectionManager( - layoutManager: layoutManager, - textStorage: textStorage, - layoutView: self, // TODO: This is an odd syntax... consider reworking this - delegate: self - ) - } - public var documentRange: NSRange { NSRange(location: 0, length: textStorage.length) } @@ -334,11 +311,8 @@ public class TextView: NSView, NSTextContent { /// Scrolls the upmost selection to the visible rect if `scrollView` is not `nil`. public func scrollSelectionToVisible() { guard let scrollView, - let selection = selectionManager - .textSelections - .sorted(by: { $0.view?.frame.minY ?? 0.0 < $1.view?.frame.minY ?? 0.0 }) - .first - else { + let selection = selectionManager.textSelections + .sorted(by: { $0.view?.frame.minY ?? 0.0 < $1.view?.frame.minY ?? 0.0 }).first else { return } var lastFrame: CGRect = .zero @@ -358,39 +332,6 @@ public class TextView: NSView, NSTextContent { } } -// MARK: - TextLayoutManagerDelegate - -extension TextView: TextLayoutManagerDelegate { - public func layoutManagerHeightDidUpdate(newHeight: CGFloat) { - updateFrameIfNeeded() - } - - public func layoutManagerMaxWidthDidChange(newWidth: CGFloat) { - updateFrameIfNeeded() - } - - public func textViewSize() -> 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) - } - } - - public func textLayoutSetNeedsDisplay() { - needsDisplay = true - needsLayout = true - } - - public func layoutManagerYAdjustment(_ yAdjustment: CGFloat) { - var point = scrollView?.documentVisibleRect.origin ?? .zero - point.y += yAdjustment - scrollView?.documentView?.scroll(point) - } -} - // MARK: - TextSelectionManagerDelegate extension TextView: TextSelectionManagerDelegate { diff --git a/Sources/CodeEditTextView/TextViewController.swift b/Sources/CodeEditTextView/TextViewController.swift index a16195863..2c380e90d 100644 --- a/Sources/CodeEditTextView/TextViewController.swift +++ b/Sources/CodeEditTextView/TextViewController.swift @@ -154,7 +154,7 @@ public class TextViewController: NSViewController { object: textView, queue: .main ) { [weak self] _ in - self?.gutterView.frame.size.height = self?.textView.frame.height ?? 0 + self?.gutterView.frame.size.height = (self?.textView.frame.height ?? 0) + 10 self?.gutterView.needsDisplay = true } From 18a958c82eb00bdb840070fe207dda23957fb779 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sun, 17 Sep 2023 12:42:19 -0500 Subject: [PATCH 41/75] QOL GutterView Improvements --- Sources/CodeEditTextView/Gutter/GutterView.swift | 15 ++++++++++++--- Sources/CodeEditTextView/TextViewController.swift | 15 +++++++++++++-- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/Sources/CodeEditTextView/Gutter/GutterView.swift b/Sources/CodeEditTextView/Gutter/GutterView.swift index 141d39be7..3df44013b 100644 --- a/Sources/CodeEditTextView/Gutter/GutterView.swift +++ b/Sources/CodeEditTextView/Gutter/GutterView.swift @@ -9,6 +9,10 @@ import AppKit import CodeEditInputView import Common +public protocol GutterViewDelegate: AnyObject { + func gutterViewWidthDidUpdate(newWidth: CGFloat) +} + public class GutterView: NSView { struct EdgeInsets: Equatable, Hashable { let leading: CGFloat @@ -37,7 +41,10 @@ public class GutterView: NSView { @Invalidating(.display) var selectedLineColor: NSColor = NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) + private(set) public var gutterWidth: CGFloat = 0 + private weak var textView: TextView? + private weak var delegate: GutterViewDelegate? private var maxWidth: CGFloat = 0 /// The maximum number of digits found for a line number. private var maxLineLength: Int = 0 @@ -49,11 +56,13 @@ public class GutterView: NSView { public init( font: NSFont, textColor: NSColor, - textView: TextView + textView: TextView, + delegate: GutterViewDelegate? = nil ) { self.font = font self.textColor = textColor self.textView = textView + self.delegate = delegate super.init(frame: .zero) wantsLayer = true @@ -100,8 +109,8 @@ public class GutterView: NSView { } if originalMaxWidth != maxWidth { - self.frame.size.width = maxWidth + edgeInsets.horizontal - textView.edgeInsets.left = maxWidth + edgeInsets.horizontal + gutterWidth = maxWidth + edgeInsets.horizontal + delegate?.gutterViewWidthDidUpdate(newWidth: maxWidth + edgeInsets.horizontal) } } diff --git a/Sources/CodeEditTextView/TextViewController.swift b/Sources/CodeEditTextView/TextViewController.swift index 2c380e90d..d1b4aca8b 100644 --- a/Sources/CodeEditTextView/TextViewController.swift +++ b/Sources/CodeEditTextView/TextViewController.swift @@ -97,7 +97,6 @@ public class TextViewController: NSViewController { ) textView.postsFrameChangedNotifications = true textView.translatesAutoresizingMaskIntoConstraints = false - textView.edgeInsets = HorizontalEdgeInsets(left: 50, right: 75) scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.contentView.postsFrameChangedNotifications = true @@ -110,7 +109,12 @@ public class TextViewController: NSViewController { scrollView.contentInsets = contentInsets } - gutterView = GutterView(font: font.rulerFont, textColor: .secondaryLabelColor, textView: textView) + gutterView = GutterView( + font: font.rulerFont, + textColor: .secondaryLabelColor, + textView: textView, + delegate: self + ) gutterView.frame.origin.y = -scrollView.contentInsets.top gutterView.updateWidthIfNeeded() scrollView.addFloatingSubview( @@ -218,3 +222,10 @@ extension TextViewController: TextViewDelegate { gutterView.needsDisplay = true } } + +extension TextViewController: GutterViewDelegate { + public func gutterViewWidthDidUpdate(newWidth: CGFloat) { + gutterView?.frame.size.width = newWidth + textView?.edgeInsets.left = newWidth + } +} From 0a00de9a6410afa040cbb23c2158834b0f2534eb Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 19 Sep 2023 14:28:09 -0500 Subject: [PATCH 42/75] Add line break strategies --- .../TextLine/LineBreakStrategy.swift | 11 +++ .../TextLine/LineFragment.swift | 6 +- .../CodeEditInputView/TextLine/TextLine.swift | 6 +- .../TextLine/Typesetter.swift | 85 ++++++++++++++++--- 4 files changed, 95 insertions(+), 13 deletions(-) create mode 100644 Sources/CodeEditInputView/TextLine/LineBreakStrategy.swift diff --git a/Sources/CodeEditInputView/TextLine/LineBreakStrategy.swift b/Sources/CodeEditInputView/TextLine/LineBreakStrategy.swift new file mode 100644 index 000000000..865edd92a --- /dev/null +++ b/Sources/CodeEditInputView/TextLine/LineBreakStrategy.swift @@ -0,0 +1,11 @@ +// +// LineBreakStrategy.swift +// +// +// Created by Khan Winter on 9/19/23. +// + +public enum LineBreakStrategy { + case word + case character +} diff --git a/Sources/CodeEditInputView/TextLine/LineFragment.swift b/Sources/CodeEditInputView/TextLine/LineFragment.swift index f2ba739db..756006e10 100644 --- a/Sources/CodeEditInputView/TextLine/LineFragment.swift +++ b/Sources/CodeEditInputView/TextLine/LineFragment.swift @@ -7,7 +7,7 @@ import AppKit -public final class LineFragment: Identifiable { +public final class LineFragment: Identifiable, Equatable { public let id = UUID() private(set) public var ctLine: CTLine public let width: CGFloat @@ -32,4 +32,8 @@ public final class LineFragment: Identifiable { self.descent = descent self.scaledHeight = height * lineHeightMultiplier } + + public static func == (lhs: LineFragment, rhs: LineFragment) -> Bool { + lhs.id == rhs.id + } } diff --git a/Sources/CodeEditInputView/TextLine/TextLine.swift b/Sources/CodeEditInputView/TextLine/TextLine.swift index 459cc0233..7287acd2c 100644 --- a/Sources/CodeEditInputView/TextLine/TextLine.swift +++ b/Sources/CodeEditInputView/TextLine/TextLine.swift @@ -9,7 +9,7 @@ import Foundation import AppKit /// Represents a displayable line of text. -public final class TextLine: Identifiable { +public final class TextLine: Identifiable, Equatable { public let id: UUID = UUID() // private weak var stringRef: NSTextStorage? private var needsLayout: Bool = true @@ -39,4 +39,8 @@ public final class TextLine: Identifiable { ) needsLayout = false } + + public static func == (lhs: TextLine, rhs: TextLine) -> Bool { + lhs.id == rhs.id + } } diff --git a/Sources/CodeEditInputView/TextLine/Typesetter.swift b/Sources/CodeEditInputView/TextLine/Typesetter.swift index a75329c12..a7c1dbc26 100644 --- a/Sources/CodeEditInputView/TextLine/Typesetter.swift +++ b/Sources/CodeEditInputView/TextLine/Typesetter.swift @@ -30,7 +30,12 @@ final class Typesetter { guard let typesetter else { return } var startIndex = 0 while startIndex < string.length { - let lineBreak = suggestLineBreak(using: typesetter, startingOffset: startIndex, constrainingWidth: maxWidth) + let lineBreak = suggestLineBreak( + using: typesetter, + strategy: .word, // TODO: Make this configurable + startingOffset: startIndex, + constrainingWidth: maxWidth + ) let lineFragment = typesetLine( range: NSRange(location: startIndex, length: lineBreak - startIndex), lineHeightMultiplier: lineHeightMultiplier @@ -63,25 +68,84 @@ final class Typesetter { // MARK: - Line Breaks + /// Suggest a line break for the given line break strategy. + /// - Parameters: + /// - typesetter: The typesetter to use. + /// - strategy: The strategy that determines a valid line break. + /// - startingOffset: Where to start breaking. + /// - constrainingWidth: The available space for the line. + /// - Returns: An offset relative to the entire string indicating where to break. private func suggestLineBreak( + using typesetter: CTTypesetter, + strategy: LineBreakStrategy, + startingOffset: Int, + constrainingWidth: CGFloat + ) -> Int { + switch strategy { + case .character: + return suggestLineBreakForCharacter( + using: typesetter, + startingOffset: startingOffset, + constrainingWidth: constrainingWidth + ) + case .word: + return suggestLineBreakForWord( + using: typesetter, + startingOffset: startingOffset, + constrainingWidth: constrainingWidth + ) + } + } + + /// Suggest a line break for the character break strategy. + /// - Parameters: + /// - typesetter: The typesetter to use. + /// - startingOffset: Where to start breaking. + /// - constrainingWidth: The available space for the line. + /// - Returns: An offset relative to the entire string indicating where to break. + private func suggestLineBreakForCharacter( 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 { - // Walk backwards until we find a valid break point. Max out at 100 characters. + guard breakIndex < string.length else { + return breakIndex + } + let substring = string.attributedSubstring(from: NSRange(location: breakIndex - 1, length: 2)).string + if substring == LineEnding.carriageReturnLineFeed.rawValue { + // Breaking in the middle of the clrf line ending + return breakIndex + 1 + } + return breakIndex + } + + /// Suggest a line break for the word break strategy. + /// - Parameters: + /// - typesetter: The typesetter to use. + /// - startingOffset: Where to start breaking. + /// - constrainingWidth: The available space for the line. + /// - Returns: An offset relative to the entire string indicating where to break. + private func suggestLineBreakForWord( + using typesetter: CTTypesetter, + startingOffset: Int, + constrainingWidth: CGFloat + ) -> Int { + let breakIndex = startingOffset + CTTypesetterSuggestClusterBreak(typesetter, startingOffset, constrainingWidth) + if breakIndex >= string.length || (breakIndex - 1 > 0 && ensureCharacterCanBreakLine(at: breakIndex - 1)) { + // Breaking either at the end of the string, or on a whitespace. + return breakIndex + } else if breakIndex - 1 > 0 { + // Try to walk backwards until we hit a whitespace or punctuation var index = breakIndex - 1 - while index > 0 && breakIndex - index > 100 { + + while breakIndex - index < 100 { if ensureCharacterCanBreakLine(at: index) { - return index - } else { - index -= 1 + return index + 1 } + index -= 1 } - return breakIndex } return breakIndex @@ -91,8 +155,7 @@ final class Typesetter { let set = CharacterSet( charactersIn: string.attributedSubstring(from: NSRange(location: index, length: 1)).string ) - return set.isSubset(of: .whitespacesAndNewlines.subtracting(.newlines)) - || set.isSubset(of: .punctuationCharacters) + return set.isSubset(of: .whitespaces) || set.isSubset(of: .punctuationCharacters) } deinit { From 37a8331d00de8711c27e2528a95437b28b96e4c4 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 19 Sep 2023 14:44:43 -0500 Subject: [PATCH 43/75] Limit word break lookback to startingOffset --- Sources/CodeEditInputView/TextLine/Typesetter.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditInputView/TextLine/Typesetter.swift b/Sources/CodeEditInputView/TextLine/Typesetter.swift index a7c1dbc26..f0244f58f 100644 --- a/Sources/CodeEditInputView/TextLine/Typesetter.swift +++ b/Sources/CodeEditInputView/TextLine/Typesetter.swift @@ -140,7 +140,7 @@ final class Typesetter { // Try to walk backwards until we hit a whitespace or punctuation var index = breakIndex - 1 - while breakIndex - index < 100 { + while breakIndex - index < 100 && index > startingOffset { if ensureCharacterCanBreakLine(at: index) { return index + 1 } From 307b0b34d0b0c0c322bfbd81dc959efb91118699 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 19 Sep 2023 15:18:53 -0500 Subject: [PATCH 44/75] Draw Selection Rects --- .../TextLayoutManager+Public.swift | 6 +- .../TextSelectionManager.swift | 160 +++++++++++++----- .../TextView/TextView+Move.swift | 6 +- .../CodeEditTextView/Gutter/GutterView.swift | 26 ++- .../Common/Extensions/NSRange+isEmpty.swift | 2 +- 5 files changed, 143 insertions(+), 57 deletions(-) diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift index 8523c5a88..8d1414a49 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift @@ -82,19 +82,19 @@ extension TextLayoutManager { let minXPos = CTLineGetOffsetForStringIndex( fragmentPosition.data.ctLine, - realRange.location - linePosition.range.location - fragmentPosition.range.location, + realRange.location - linePosition.range.location, // CTLines have the same relative range as the line nil ) let maxXPos = CTLineGetOffsetForStringIndex( fragmentPosition.data.ctLine, - realRange.max - linePosition.range.location - fragmentPosition.range.location, + realRange.max - linePosition.range.location, nil ) return CGRect( x: minXPos + edgeInsets.left, y: linePosition.yPos + fragmentPosition.yPos, - width: (maxXPos - minXPos) + edgeInsets.left, + width: maxXPos - minXPos, height: fragmentPosition.data.scaledHeight ) } diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift index 01ae68ccc..493f78292 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift @@ -65,6 +65,7 @@ public class TextSelectionManager: NSObject { } public var selectedLineBackgroundColor: NSColor = NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) + public var selectionBackgroundColor: NSColor = NSColor.selectedTextBackgroundColor private var markedText: [MarkedText] = [] private(set) public var textSelections: [TextSelection] = [] @@ -96,6 +97,7 @@ public class TextSelectionManager: NSObject { selection.suggestedXPos = layoutManager?.rectForOffset(range.location)?.minX textSelections = [selection] updateSelectionViews() + NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification)) } public func setSelectedRanges(_ ranges: [NSRange]) { @@ -106,6 +108,7 @@ public class TextSelectionManager: NSObject { return selection } updateSelectionViews() + NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification)) } // MARK: - Selection Views @@ -113,29 +116,34 @@ public class TextSelectionManager: NSObject { func updateSelectionViews() { var didUpdate: Bool = false - for textSelection in textSelections where textSelection.range.isEmpty { - let lineFragment = layoutManager? - .textLineForOffset(textSelection.range.location)? - .data - .lineFragments - .first - let cursorOrigin = (layoutManager?.rectForOffset(textSelection.range.location) ?? .zero).origin - if textSelection.view == nil - || textSelection.view?.frame.origin != cursorOrigin - || textSelection.view?.frame.height != lineFragment?.data.scaledHeight ?? 0 { + for textSelection in textSelections { + if textSelection.range.isEmpty { + let lineFragment = layoutManager? + .textLineForOffset(textSelection.range.location)? + .data + .lineFragments + .first + let cursorOrigin = (layoutManager?.rectForOffset(textSelection.range.location) ?? .zero).origin + if textSelection.view == nil + || textSelection.view?.frame.origin != cursorOrigin + || textSelection.view?.frame.height != lineFragment?.data.scaledHeight ?? 0 { + textSelection.view?.removeFromSuperview() + let cursorView = CursorView() + cursorView.frame.origin = cursorOrigin + cursorView.frame.size.height = lineFragment?.data.scaledHeight ?? 0 + layoutView?.addSubview(cursorView) + textSelection.view = cursorView + didUpdate = true + } + } else if !textSelection.range.isEmpty && textSelection.view != nil { textSelection.view?.removeFromSuperview() - let cursorView = CursorView() - cursorView.frame.origin = cursorOrigin - cursorView.frame.size.height = lineFragment?.data.scaledHeight ?? 0 - layoutView?.addSubview(cursorView) - textSelection.view = cursorView + textSelection.view = nil didUpdate = true } } if didUpdate { delegate?.setNeedsDisplay() - NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification)) } } @@ -164,41 +172,98 @@ public class TextSelectionManager: NSObject { // For each selection in the rect for textSelection in textSelections { if textSelection.range.isEmpty { - // Highlight the line - guard let linePosition = layoutManager?.textLineForOffset(textSelection.range.location) else { - continue - } - let selectionRect = CGRect( - x: rect.minX, - y: layoutManager?.rectForOffset(linePosition.range.location)?.minY ?? linePosition.yPos, - width: rect.width, - height: linePosition.height + drawHighlightedLine(in: rect, for: textSelection, context: context) + } else { + drawSelectedRange(in: rect, range: textSelection.range, context: context) + } + } + context.restoreGState() + } + + /// Draws a highlighted line in the given rect. + /// - Parameters: + /// - rect: The rect to draw in. + /// - textSelection: The selection to draw. + /// - context: The context to draw in. + private func drawHighlightedLine(in rect: NSRect, for textSelection: TextSelection, context: CGContext) { + guard let linePosition = layoutManager?.textLineForOffset(textSelection.range.location) else { + return + } + context.saveGState() + let selectionRect = CGRect( + x: rect.minX, + y: linePosition.yPos, + width: rect.width, + height: linePosition.height + ) + if selectionRect.intersects(rect) { + context.setFillColor(selectedLineBackgroundColor.cgColor) + context.fill(selectionRect) + } + context.restoreGState() + } + + /// Draws a selected range in the given context. + /// - Parameters: + /// - rect: The rect to draw in. + /// - range: The range to highlight. + /// - context: The context to draw in. + private func drawSelectedRange(in rect: NSRect, range: NSRange, context: CGContext) { + guard let layoutManager else { return } + context.saveGState() + context.setFillColor(selectionBackgroundColor.cgColor) + + var fillRects = [CGRect]() + + for linePosition in layoutManager.lineStorage.linesInRange(range) { + if linePosition.range.intersection(range) == linePosition.range { + // If the selected range contains the entire line + fillRects.append( + CGRect( + x: rect.minX, + y: linePosition.yPos, + width: rect.width, + height: linePosition.height + ) ) - if selectionRect.intersects(rect) { - context.setFillColor(selectedLineBackgroundColor.cgColor) - context.fill(selectionRect) - } } else { - // 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) { -// -// } -// } + // The selected range contains some portion of the line + for fragmentPosition in linePosition.data.lineFragments { + guard let fragmentRange = fragmentPosition + .range + .shifted(by: linePosition.range.location), + let intersectionRange = fragmentRange.intersection(range), + let minRect = layoutManager.rectForOffset(intersectionRange.location) else { + continue + } + + let maxRect: CGRect + if fragmentRange.max <= range.max || range.contains(fragmentRange.max) { + maxRect = CGRect( + x: rect.maxX, + y: fragmentPosition.yPos + linePosition.yPos, + width: 0, + height: fragmentPosition.height + ) + } else if let maxFragmentRect = layoutManager.rectForOffset(intersectionRange.max) { + maxRect = maxFragmentRect + } else { + continue + } + + fillRects.append( + CGRect( + x: minRect.origin.x, + y: minRect.origin.y, + width: maxRect.minX - minRect.minX, + height: max(minRect.height, maxRect.height) + ) + ) + } } } + + context.fill(fillRects) context.restoreGState() } } @@ -240,5 +305,6 @@ extension TextSelectionManager: NSTextStorageDelegate { } } updateSelectionViews() + NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification)) } } diff --git a/Sources/CodeEditInputView/TextView/TextView+Move.swift b/Sources/CodeEditInputView/TextView/TextView+Move.swift index d89e259c1..1ae5cf346 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Move.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Move.swift @@ -45,6 +45,7 @@ extension TextView { selectionManager.updateSelectionViews() scrollSelectionToVisible() setNeedsDisplay() + NotificationCenter.default.post(Notification(name: TextSelectionManager.selectionChangedNotification)) } /// Moves the cursors left one character extending the current selection. @@ -64,6 +65,7 @@ extension TextView { selectionManager.updateSelectionViews() scrollSelectionToVisible() setNeedsDisplay() + NotificationCenter.default.post(Notification(name: TextSelectionManager.selectionChangedNotification)) } /// Moves the cursors right one character extending the current selection. @@ -177,6 +179,8 @@ extension TextView { selectionManager.updateSelectionViews() scrollSelectionToVisible() setNeedsDisplay() + + NotificationCenter.default.post(Notification(name: TextSelectionManager.selectionChangedNotification)) } /// Moves a single selection determined by the direction and destination provided. @@ -211,7 +215,7 @@ extension TextView { selection.suggestedXPos = selection.suggestedXPos ?? layoutManager?.rectForOffset(range.max)?.minX } case .forward: - selection.suggestedXPos = layoutManager?.rectForOffset(range.location)?.minX + selection.suggestedXPos = layoutManager?.rectForOffset(range.max)?.minX case .backward: selection.suggestedXPos = layoutManager?.rectForOffset(range.location)?.minX } diff --git a/Sources/CodeEditTextView/Gutter/GutterView.swift b/Sources/CodeEditTextView/Gutter/GutterView.swift index 3df44013b..619403ce7 100644 --- a/Sources/CodeEditTextView/Gutter/GutterView.swift +++ b/Sources/CodeEditTextView/Gutter/GutterView.swift @@ -38,6 +38,9 @@ public class GutterView: NSView { @Invalidating(.display) var highlightSelectedLines: Bool = true + @Invalidating(.display) + var selectedLineTextColor: NSColor = .textColor + @Invalidating(.display) var selectedLineColor: NSColor = NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) @@ -123,7 +126,8 @@ public class GutterView: NSView { } context.saveGState() context.setFillColor(selectedLineColor.cgColor) - for selection in selectionManager.textSelections { + for selection in selectionManager.textSelections + where selection.range.isEmpty { guard let line = textView.layoutManager.textLineForOffset(selection.range.location), visibleRange.intersection(line.range) != nil else { continue @@ -142,14 +146,26 @@ public class GutterView: NSView { private func drawLineNumbers(_ context: CGContext) { guard let textView = textView else { return } - let attributes: [NSAttributedString.Key: Any] = [ - .font: font, - .foregroundColor: textColor - ] + var attributes: [NSAttributedString.Key: Any] = [.font: font] + + var selectionRangeMap = IndexSet() + textView.selectionManager?.textSelections.forEach { + if $0.range.isEmpty { + selectionRangeMap.insert($0.range.location) + } else { + selectionRangeMap.insert(range: $0.range) + } + } context.saveGState() context.textMatrix = CGAffineTransform(scaleX: 1, y: -1) for linePosition in textView.layoutManager.visibleLines() { + if selectionRangeMap.intersects(integersIn: linePosition.range) { + attributes[.foregroundColor] = selectedLineTextColor + } else { + attributes[.foregroundColor] = textColor + } + let ctLine = CTLineCreateWithAttributedString( NSAttributedString(string: "\(linePosition.index + 1)", attributes: attributes) ) diff --git a/Sources/Common/Extensions/NSRange+isEmpty.swift b/Sources/Common/Extensions/NSRange+isEmpty.swift index 05c0c9496..8863c996c 100644 --- a/Sources/Common/Extensions/NSRange+isEmpty.swift +++ b/Sources/Common/Extensions/NSRange+isEmpty.swift @@ -8,7 +8,7 @@ import Foundation public extension NSRange { - public var isEmpty: Bool { + var isEmpty: Bool { length == 0 } } From f22ae5a509178ccc18009bdbf55494c7959104ad Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 2 Oct 2023 14:30:00 -0500 Subject: [PATCH 45/75] Drag To Select, Autoscroll On Drag --- Package.resolved | 17 +- .../TextSelectionManager+Move.swift | 151 ++++++++++++++++ .../TextSelectionManager.swift | 21 ++- .../TextView/TextView+Drag.swift | 32 ++++ .../TextView/TextView+Move.swift | 170 +++++------------- .../CodeEditInputView/TextView/TextView.swift | 25 ++- 6 files changed, 281 insertions(+), 135 deletions(-) create mode 100644 Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Move.swift create mode 100644 Sources/CodeEditInputView/TextView/TextView+Drag.swift diff --git a/Package.resolved b/Package.resolved index 44de3fef7..2bbf766f8 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditLanguages.git", "state" : { - "revision" : "aa7d922b2aa783ae6f2a1a2cb7010ae62b700e17", - "version" : "0.1.16" + "revision" : "af29ab4a15474a0a38ef88ef65c20e58a0812e43", + "version" : "0.1.17" } }, { @@ -23,8 +23,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/krzyzanowskim/STTextView.git", "state" : { - "branch" : "897c5ff", - "revision" : "897c5ffe3c6b35664ab085d43238b3a95e79440d" + "revision" : "1046965bd62dc05cf17beb4b4e901cdae851395e", + "version" : "0.8.7" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", + "version" : "1.0.4" } }, { diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Move.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Move.swift new file mode 100644 index 000000000..832580669 --- /dev/null +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Move.swift @@ -0,0 +1,151 @@ +// +// TextSelectionManager+Move.swift +// +// +// Created by Khan Winter on 9/20/23. +// + +import AppKit +import Common + +extension TextSelectionManager { + /// Moves all selections, determined by the direction and destination provided. + /// + /// Also handles updating the selection views and marks the view as needing display. + /// + /// - Parameters: + /// - direction: The direction to modify all selections. + /// - destination: The destination to move the selections by. + /// - modifySelection: Set to `true` to modify the selections instead of replacing it. + public func moveSelections( + direction: TextSelectionManager.Direction, + destination: TextSelectionManager.Destination, + modifySelection: Bool = false + ) { + textSelections.forEach { + moveSelection( + selection: $0, + direction: direction, + destination: destination, + modifySelection: modifySelection + ) + } + updateSelectionViews() + delegate?.setNeedsDisplay() + NotificationCenter.default.post(Notification(name: TextSelectionManager.selectionChangedNotification)) + } + + /// Moves a single selection determined by the direction and destination provided. + /// - Parameters: + /// - selection: The selection to modify. + /// - direction: The direction to move in. + /// - destination: The destination of the move. + /// - modifySelection: Set to `true` to modify the selection instead of replacing it. + private func moveSelection( + selection: TextSelectionManager.TextSelection, + direction: TextSelectionManager.Direction, + destination: TextSelectionManager.Destination, + modifySelection: Bool = false + ) { + if !selection.range.isEmpty + && !modifySelection + && (direction == .backward || direction == .forward) + && destination == .character { + if direction == .forward { + selection.range.location = selection.range.max + } + selection.range.length = 0 + return + } + + // Find where to modify the selection from. + let startLocation = findSelectionStartLocation(selection, direction: direction) + + // Update pivot if necessary + updateSelectionPivot(selection, direction: direction) + + let range = rangeOfSelection( + from: startLocation, + direction: direction, + destination: destination, + suggestedXPos: selection.suggestedXPos + ) + + // Update the suggested x position + updateSelectionXPos(selection, newRange: range, direction: direction, destination: destination) + + // Update the selection range + updateSelectionRange( + selection, + newRange: range, + modifySelection: modifySelection, + direction: direction, + destination: destination + ) + } + + private func findSelectionStartLocation( + _ selection: TextSelectionManager.TextSelection, + direction: TextSelectionManager.Direction + ) -> Int { + if direction == .forward || (direction == .down && !selection.range.isEmpty) { + return selection.range.max + } else { + return selection.range.location + } + } + + private func updateSelectionPivot( + _ selection: TextSelectionManager.TextSelection, + direction: TextSelectionManager.Direction + ) { + if selection.pivot == nil { + // TODO: Pivot!!!! + } + } + + private func updateSelectionXPos( + _ selection: TextSelectionManager.TextSelection, + newRange range: NSRange, + direction: TextSelectionManager.Direction, + destination: TextSelectionManager.Destination + ) { + switch direction { + case .up: + if destination != .line { + selection.suggestedXPos = selection.suggestedXPos ?? layoutManager?.rectForOffset(range.location)?.minX + } else { + selection.suggestedXPos = nil + } + case .down: + if destination == .line { + selection.suggestedXPos = layoutManager?.rectForOffset(range.max)?.minX + } else { + selection.suggestedXPos = selection.suggestedXPos ?? layoutManager?.rectForOffset(range.max)?.minX + } + case .forward: + selection.suggestedXPos = layoutManager?.rectForOffset(range.max)?.minX + case .backward: + selection.suggestedXPos = layoutManager?.rectForOffset(range.location)?.minX + } + } + + private func updateSelectionRange( + _ selection: TextSelectionManager.TextSelection, + newRange range: NSRange, + modifySelection: Bool, + direction: TextSelectionManager.Direction, + destination: TextSelectionManager.Destination + ) { + if modifySelection { + selection.range.formUnion(range) + } else { + switch direction { + case .up, .backward: + selection.range = NSRange(location: range.location, length: 0) + case .down, .forward: + selection.range = NSRange(location: range.max, length: 0) + } + } + } +} diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift index 493f78292..db04202d9 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift @@ -30,7 +30,10 @@ public class TextSelectionManager: NSObject { public class TextSelection { public var range: NSRange internal weak var view: CursorView? + internal var boundingRect: CGRect = .zero internal var suggestedXPos: CGFloat? + /// The position this selection should 'rotate' around when modifying selections. + internal var pivot: Int? init(range: NSRange, view: CursorView? = nil) { self.range = range @@ -125,14 +128,15 @@ public class TextSelectionManager: NSObject { .first let cursorOrigin = (layoutManager?.rectForOffset(textSelection.range.location) ?? .zero).origin if textSelection.view == nil - || textSelection.view?.frame.origin != cursorOrigin - || textSelection.view?.frame.height != lineFragment?.data.scaledHeight ?? 0 { + || textSelection.boundingRect.origin != cursorOrigin + || textSelection.boundingRect.height != lineFragment?.data.scaledHeight ?? 0 { textSelection.view?.removeFromSuperview() let cursorView = CursorView() cursorView.frame.origin = cursorOrigin cursorView.frame.size.height = lineFragment?.data.scaledHeight ?? 0 layoutView?.addSubview(cursorView) textSelection.view = cursorView + textSelection.boundingRect = cursorView.frame didUpdate = true } } else if !textSelection.range.isEmpty && textSelection.view != nil { @@ -174,7 +178,7 @@ public class TextSelectionManager: NSObject { if textSelection.range.isEmpty { drawHighlightedLine(in: rect, for: textSelection, context: context) } else { - drawSelectedRange(in: rect, range: textSelection.range, context: context) + drawSelectedRange(in: rect, for: textSelection, context: context) } } context.restoreGState() @@ -208,8 +212,9 @@ public class TextSelectionManager: NSObject { /// - rect: The rect to draw in. /// - range: The range to highlight. /// - context: The context to draw in. - private func drawSelectedRange(in rect: NSRect, range: NSRange, context: CGContext) { + private func drawSelectedRange(in rect: NSRect, for textSelection: TextSelection, context: CGContext) { guard let layoutManager else { return } + let range = textSelection.range context.saveGState() context.setFillColor(selectionBackgroundColor.cgColor) @@ -263,6 +268,14 @@ public class TextSelectionManager: NSObject { } } + let min = fillRects.min(by: { $0.origin.y < $1.origin.y })?.origin ?? .zero + let max = fillRects.max(by: { $0.origin.y < $1.origin.y }) ?? .zero + let size = CGSize(width: max.maxX - min.x, height: max.maxY - min.y) + textSelection.boundingRect = CGRect( + origin: min, + size: size + ) + context.fill(fillRects) context.restoreGState() } diff --git a/Sources/CodeEditInputView/TextView/TextView+Drag.swift b/Sources/CodeEditInputView/TextView/TextView+Drag.swift new file mode 100644 index 000000000..8dfd3fdf0 --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+Drag.swift @@ -0,0 +1,32 @@ +// +// TextView+Drag.swift +// +// +// Created by Khan Winter on 9/19/23. +// + +import AppKit +import Common + +extension TextView { + public override func mouseDragged(with event: NSEvent) { + if mouseDragAnchor == nil { + mouseDragAnchor = convert(event.locationInWindow, from: nil) + super.mouseDragged(with: event) + } else { + guard let mouseDragAnchor, + let startPosition = layoutManager.textOffsetAtPoint(mouseDragAnchor), + let endPosition = layoutManager.textOffsetAtPoint(convert(event.locationInWindow, from: nil)) else { + return + } + selectionManager.setSelectedRange( + NSRange( + location: min(startPosition, endPosition), + length: max(startPosition, endPosition) - min(startPosition, endPosition) + ) + ) + setNeedsDisplay() + self.autoscroll(with: event) + } + } +} diff --git a/Sources/CodeEditInputView/TextView/TextView+Move.swift b/Sources/CodeEditInputView/TextView/TextView+Move.swift index 1ae5cf346..d31c4a4e4 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Move.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Move.swift @@ -8,227 +8,149 @@ import Foundation extension TextView { - // TODO: Move up/down character need to remember the xPos they started at. - // Comment for TODO. When moving up/down users expect to move across lines of different lengths while keeping their - // cursor as close as possible to the original x position. This needs to be implemented. - /// Moves the cursors up one character. override public func moveUp(_ sender: Any?) { - moveSelections(direction: .up, destination: .character) + selectionManager.moveSelections(direction: .up, destination: .character) + scrollSelectionToVisible() } /// Moves the cursors up one character extending the current selection. override public func moveUpAndModifySelection(_ sender: Any?) { - moveSelections(direction: .up, destination: .character, modifySelection: true) + selectionManager.moveSelections(direction: .up, destination: .character, modifySelection: true) + scrollSelectionToVisible() } /// Moves the cursors down one character. override public func moveDown(_ sender: Any?) { - moveSelections(direction: .down, destination: .character) + selectionManager.moveSelections(direction: .down, destination: .character) + scrollSelectionToVisible() } /// Moves the cursors down one character extending the current selection. override public func moveDownAndModifySelection(_ sender: Any?) { - moveSelections(direction: .down, destination: .character, modifySelection: true) + selectionManager.moveSelections(direction: .down, destination: .character, modifySelection: true) + scrollSelectionToVisible() } /// Moves the cursors left one character. override public func moveLeft(_ sender: Any?) { - selectionManager.textSelections.forEach { selection in - if selection.range.isEmpty { - moveSelection(selection: selection, direction: .backward, destination: .character) - } else { - selection.range.location = selection.range.max - selection.range.length = 0 - } - } - selectionManager.updateSelectionViews() + selectionManager.moveSelections(direction: .backward, destination: .character) scrollSelectionToVisible() - setNeedsDisplay() - NotificationCenter.default.post(Notification(name: TextSelectionManager.selectionChangedNotification)) } /// Moves the cursors left one character extending the current selection. override public func moveLeftAndModifySelection(_ sender: Any?) { - moveSelections(direction: .backward, destination: .character, modifySelection: true) + selectionManager.moveSelections(direction: .backward, destination: .character, modifySelection: true) + scrollSelectionToVisible() } /// Moves the cursors right one character. override public func moveRight(_ sender: Any?) { - selectionManager.textSelections.forEach { selection in - if selection.range.isEmpty { - moveSelection(selection: selection, direction: .forward, destination: .character) - } else { - selection.range.length = 0 - } - } - selectionManager.updateSelectionViews() + selectionManager.moveSelections(direction: .forward, destination: .character) scrollSelectionToVisible() - setNeedsDisplay() - NotificationCenter.default.post(Notification(name: TextSelectionManager.selectionChangedNotification)) } /// Moves the cursors right one character extending the current selection. override public func moveRightAndModifySelection(_ sender: Any?) { - moveSelections(direction: .forward, destination: .character, modifySelection: true) + selectionManager.moveSelections(direction: .forward, destination: .character, modifySelection: true) + scrollSelectionToVisible() } /// Moves the cursors left one word. override public func moveWordLeft(_ sender: Any?) { - moveSelections(direction: .backward, destination: .word) + selectionManager.moveSelections(direction: .backward, destination: .word) + scrollSelectionToVisible() } /// Moves the cursors left one word extending the current selection. override public func moveWordLeftAndModifySelection(_ sender: Any?) { - moveSelections(direction: .backward, destination: .word, modifySelection: true) + selectionManager.moveSelections(direction: .backward, destination: .word, modifySelection: true) + scrollSelectionToVisible() } /// Moves the cursors right one word. override public func moveWordRight(_ sender: Any?) { - moveSelections(direction: .forward, destination: .word) + selectionManager.moveSelections(direction: .forward, destination: .word) + scrollSelectionToVisible() } /// Moves the cursors right one word extending the current selection. override public func moveWordRightAndModifySelection(_ sender: Any?) { - moveSelections(direction: .forward, destination: .word, modifySelection: true) + selectionManager.moveSelections(direction: .forward, destination: .word, modifySelection: true) + scrollSelectionToVisible() } /// Moves the cursors left to the end of the line. override public func moveToLeftEndOfLine(_ sender: Any?) { - moveSelections(direction: .backward, destination: .line) + selectionManager.moveSelections(direction: .backward, destination: .line) + scrollSelectionToVisible() } /// Moves the cursors left to the end of the line extending the current selection. override public func moveToLeftEndOfLineAndModifySelection(_ sender: Any?) { - moveSelections(direction: .backward, destination: .line, modifySelection: true) + selectionManager.moveSelections(direction: .backward, destination: .line, modifySelection: true) + scrollSelectionToVisible() } /// Moves the cursors right to the end of the line. override public func moveToRightEndOfLine(_ sender: Any?) { - moveSelections(direction: .forward, destination: .line) + selectionManager.moveSelections(direction: .forward, destination: .line) + scrollSelectionToVisible() } /// Moves the cursors right to the end of the line extending the current selection. override public func moveToRightEndOfLineAndModifySelection(_ sender: Any?) { - moveSelections(direction: .forward, destination: .line, modifySelection: true) + selectionManager.moveSelections(direction: .forward, destination: .line, modifySelection: true) + scrollSelectionToVisible() } /// Moves the cursors to the beginning of the line, if pressed again selects the next line up. override public func moveToBeginningOfParagraph(_ sender: Any?) { - moveSelections(direction: .up, destination: .line) + selectionManager.moveSelections(direction: .up, destination: .line) + scrollSelectionToVisible() } /// Moves the cursors to the beginning of the line, if pressed again selects the next line up extending the current /// selection. override public func moveToBeginningOfParagraphAndModifySelection(_ sender: Any?) { - moveSelections(direction: .up, destination: .line, modifySelection: true) + selectionManager.moveSelections(direction: .up, destination: .line, modifySelection: true) + scrollSelectionToVisible() } /// Moves the cursors to the end of the line, if pressed again selects the next line up. override public func moveToEndOfParagraph(_ sender: Any?) { - moveSelections(direction: .down, destination: .line) + selectionManager.moveSelections(direction: .down, destination: .line) + scrollSelectionToVisible() } /// Moves the cursors to the end of the line, if pressed again selects the next line up extending the current /// selection. override public func moveToEndOfParagraphAndModifySelection(_ sender: Any?) { - moveSelections(direction: .down, destination: .line, modifySelection: true) + selectionManager.moveSelections(direction: .down, destination: .line, modifySelection: true) + scrollSelectionToVisible() } /// Moves the cursors to the beginning of the document. override public func moveToBeginningOfDocument(_ sender: Any?) { - moveSelections(direction: .up, destination: .document) + selectionManager.moveSelections(direction: .up, destination: .document) + scrollSelectionToVisible() } /// Moves the cursors to the beginning of the document extending the current selection. override public func moveToBeginningOfDocumentAndModifySelection(_ sender: Any?) { - moveSelections(direction: .up, destination: .document, modifySelection: true) + selectionManager.moveSelections(direction: .up, destination: .document, modifySelection: true) + scrollSelectionToVisible() } /// Moves the cursors to the end of the document. override public func moveToEndOfDocument(_ sender: Any?) { - moveSelections(direction: .down, destination: .document) + selectionManager.moveSelections(direction: .down, destination: .document) + scrollSelectionToVisible() } /// Moves the cursors to the end of the document extending the current selection. override public func moveToEndOfDocumentAndModifySelection(_ sender: Any?) { - moveSelections(direction: .down, destination: .document, modifySelection: true) - } - - /// Moves all selections, determined by the direction and destination provided. - /// - /// Also handles updating the selection views and marks the view as needing display. - /// - /// - Parameters: - /// - direction: The direction to modify all selections. - /// - destination: The destination to move the selections by. - /// - modifySelection: Set to `true` to modify the selections instead of replacing it. - fileprivate func moveSelections( - direction: TextSelectionManager.Direction, - destination: TextSelectionManager.Destination, - modifySelection: Bool = false - ) { - selectionManager.textSelections.forEach { - moveSelection( - selection: $0, - direction: direction, - destination: destination, - modifySelection: modifySelection - ) - } - selectionManager.updateSelectionViews() - scrollSelectionToVisible() - setNeedsDisplay() - - NotificationCenter.default.post(Notification(name: TextSelectionManager.selectionChangedNotification)) - } - - /// Moves a single selection determined by the direction and destination provided. - /// - Parameters: - /// - selection: The selection to modify. - /// - direction: The direction to move in. - /// - destination: The destination of the move. - /// - modifySelection: Set to `true` to modify the selection instead of replacing it. - fileprivate func moveSelection( - selection: TextSelectionManager.TextSelection, - direction: TextSelectionManager.Direction, - destination: TextSelectionManager.Destination, - modifySelection: Bool = false - ) { - let range = selectionManager.rangeOfSelection( - from: direction == .forward ? selection.range.max : selection.range.location, - direction: direction, - destination: destination, - suggestedXPos: selection.suggestedXPos - ) - switch direction { - case .up: - if destination != .line { - selection.suggestedXPos = selection.suggestedXPos ?? layoutManager?.rectForOffset(range.location)?.minX - } else { - selection.suggestedXPos = nil - } - case .down: - if destination == .line { - selection.suggestedXPos = layoutManager?.rectForOffset(range.max)?.minX - } else { - selection.suggestedXPos = selection.suggestedXPos ?? layoutManager?.rectForOffset(range.max)?.minX - } - case .forward: - selection.suggestedXPos = layoutManager?.rectForOffset(range.max)?.minX - case .backward: - selection.suggestedXPos = layoutManager?.rectForOffset(range.location)?.minX - } - - if modifySelection { - selection.range.formUnion(range) - } else { - switch direction { - case .up, .backward: - selection.range = NSRange(location: range.location, length: 0) - case .down, .forward: - selection.range = NSRange(location: range.max, length: 0) - } - } + selectionManager.moveSelections(direction: .down, destination: .document, modifySelection: true) + scrollSelectionToVisible() } } diff --git a/Sources/CodeEditInputView/TextView/TextView.swift b/Sources/CodeEditInputView/TextView/TextView.swift index 64a33db78..45742cf57 100644 --- a/Sources/CodeEditInputView/TextView/TextView.swift +++ b/Sources/CodeEditInputView/TextView/TextView.swift @@ -86,6 +86,8 @@ public class TextView: NSView, NSTextContent { // MARK: - Private Properties internal var isFirstResponder: Bool = false + internal var mouseDragAnchor: CGPoint? + internal var mouseDragTimer: Timer? var _undoManager: CEUndoManager? @objc dynamic open var allowsUndo: Bool @@ -224,11 +226,27 @@ public class TextView: NSView, NSTextContent { selectionManager.setSelectedRange(NSRange(location: offset, length: 0)) } + mouseDragTimer?.invalidate() + // https://cocoadev.github.io/AutoScrolling/ (fired at ~45Hz) + mouseDragTimer = Timer.scheduledTimer(withTimeInterval: 0.022, repeats: true) { [weak self] _ in + if let event = self?.window?.currentEvent, event.type == .leftMouseDragged { + self?.mouseDragged(with: event) + self?.autoscroll(with: event) + } + } + if !self.isFirstResponder { self.window?.makeFirstResponder(self) } } + override public func mouseUp(with event: NSEvent) { + mouseDragAnchor = nil + mouseDragTimer?.invalidate() + mouseDragTimer = nil + super.mouseUp(with: event) + } + // MARK: - Layout override public func draw(_ dirtyRect: NSRect) { @@ -312,14 +330,15 @@ public class TextView: NSView, NSTextContent { public func scrollSelectionToVisible() { guard let scrollView, let selection = selectionManager.textSelections - .sorted(by: { $0.view?.frame.minY ?? 0.0 < $1.view?.frame.minY ?? 0.0 }).first else { + .sorted(by: { $0.boundingRect.origin.y < $1.boundingRect.origin.y }).first else { return } var lastFrame: CGRect = .zero - while lastFrame != selection.view?.frame, let view = selection.view { - lastFrame = view.frame + while lastFrame != selection.boundingRect { + lastFrame = selection.boundingRect layoutManager.layoutLines() selectionManager.updateSelectionViews() + selectionManager.drawSelections(in: visibleRect) } scrollView.contentView.scrollToVisible(lastFrame) } From 7046569900b7beddc897e8e976555939bd4a4361 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 5 Oct 2023 22:02:48 -0500 Subject: [PATCH 46/75] Finalize Selection Modification API Finalizes the selection modification API to correctly modify selections moved across a "pivot" point, similar to how the default macOS selection modification works. --- .../TextSelectionManager+Move.swift | 84 ++++++++++++++++--- 1 file changed, 73 insertions(+), 11 deletions(-) diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Move.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Move.swift index 832580669..61754a8a1 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Move.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Move.swift @@ -58,11 +58,17 @@ extension TextSelectionManager { return } - // Find where to modify the selection from. - let startLocation = findSelectionStartLocation(selection, direction: direction) - // Update pivot if necessary - updateSelectionPivot(selection, direction: direction) + if modifySelection { + updateSelectionPivot(selection, direction: direction) + } + + // Find where to modify the selection from. + let startLocation = findSelectionStartLocation( + selection, + direction: direction, + modifySelection: modifySelection + ) let range = rangeOfSelection( from: startLocation, @@ -86,12 +92,34 @@ extension TextSelectionManager { private func findSelectionStartLocation( _ selection: TextSelectionManager.TextSelection, - direction: TextSelectionManager.Direction + direction: TextSelectionManager.Direction, + modifySelection: Bool ) -> Int { - if direction == .forward || (direction == .down && !selection.range.isEmpty) { - return selection.range.max + if modifySelection { + guard let pivot = selection.pivot else { + assertionFailure("Pivot should always exist when modifying a selection.") + return 0 + } + switch direction { + case .up, .forward: + if pivot > selection.range.location { + return selection.range.location + } else { + return selection.range.max + } + case .down, .backward: + if pivot < selection.range.max { + return selection.range.max + } else { + return selection.range.location + } + } } else { - return selection.range.location + if direction == .forward || (direction == .down && !selection.range.isEmpty) { + return selection.range.max + } else { + return selection.range.location + } } } @@ -99,8 +127,16 @@ extension TextSelectionManager { _ selection: TextSelectionManager.TextSelection, direction: TextSelectionManager.Direction ) { - if selection.pivot == nil { - // TODO: Pivot!!!! + guard selection.pivot == nil else { return } + switch direction { + case .up: + selection.pivot = selection.range.max + case .down: + selection.pivot = selection.range.location + case .forward: + selection.pivot = selection.range.location + case .backward: + selection.pivot = selection.range.max } } @@ -138,13 +174,39 @@ extension TextSelectionManager { destination: TextSelectionManager.Destination ) { if modifySelection { - selection.range.formUnion(range) + guard let pivot = selection.pivot else { + assertionFailure("Pivot should always exist when modifying a selection.") + return + } + switch direction { + case .down, .forward: + if range.contains(pivot) { + selection.range.location = pivot + selection.range.length = range.length - (pivot - range.location) + } else if pivot > selection.range.location { + selection.range.location += range.length + selection.range.length -= range.length + } else { + selection.range.formUnion(range) + } + case .up, .backward: + if range.contains(pivot) { + selection.range.location = range.location + selection.range.length = pivot - range.location + } else if pivot < selection.range.max { + selection.range.length -= range.length + } else { + selection.range.formUnion(range) + } + } } else { switch direction { case .up, .backward: selection.range = NSRange(location: range.location, length: 0) + selection.pivot = range.location case .down, .forward: selection.range = NSRange(location: range.max, length: 0) + selection.pivot = range.max } } } From 668b294127e7a5ba7e64aa99bb083eadfd19c1ce Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 6 Oct 2023 19:20:58 -0500 Subject: [PATCH 47/75] Fix Gutter Clipping, Improve Insert Performance Fixes the gutter view clipping slightly on the bottom of the view when scrolling. Reworks the `insert` and `metaFixup` methods to use `Unmanaged` values to walk the tree, resulting in a 30% increase in benchmark speed. --- .../TextLineStorage+Node.swift | 26 ++++----- .../TextLineStorage/TextLineStorage.swift | 54 +++++++++++++------ .../CodeEditTextView/Gutter/GutterView.swift | 7 +++ .../TextLayoutLineStorageTests.swift | 5 +- 4 files changed, 62 insertions(+), 30 deletions(-) diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift index 6afb11643..4db4534c2 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift @@ -50,12 +50,12 @@ extension TextLineStorage { case black } - final class Node { + final class Node { // The length of the text line var length: Int // The height of this text line var height: CGFloat - var data: Data + var data: NodeData // The offset in characters of the entire left subtree var leftSubtreeOffset: Int @@ -64,21 +64,21 @@ extension TextLineStorage { // The number of nodes in the left subtree var leftSubtreeCount: Int - 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, - data: Data, + data: NodeData, leftSubtreeOffset: Int, leftSubtreeHeight: CGFloat, leftSubtreeCount: Int, 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 @@ -93,7 +93,7 @@ extension TextLineStorage { self.color = color } - func sibling() -> Node? { + func sibling() -> Node? { if parent?.left === self { return parent?.right } else { @@ -101,7 +101,7 @@ extension TextLineStorage { } } - func minimum() -> Node { + func minimum() -> Node { if let left { return left.minimum() } else { @@ -109,7 +109,7 @@ extension TextLineStorage { } } - func maximum() -> Node { + func maximum() -> Node { if let right { return right.maximum() } else { @@ -117,7 +117,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/CodeEditInputView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift index 43e1ca467..d49d9bd1b 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift @@ -11,6 +11,18 @@ import Foundation // Specifically, all rotation methods, fixup methods, and internal search methods must be kept private. // swiftlint:disable file_length +// There is a lot of ugly `Unmanaged` code in this class. This is due to the fact that Swift often has a hard time +// optimizing retain/release calls for object trees. For instance, the `metaFixup` method has a lot of retain/release +// calls to each node/parent as we do a little walk up the tree. +// +// Using Unmanaged references resulted in a -30% decrease (0.667s -> 0.474s) in the +// TextLayoutLineStorageTests.test_insertPerformance benchmark when initially tested, and similar optimizations +// have also since been implemented in `insert`. +// +// See: +// - https://github.com/apple/swift/blob/main/docs/OptimizationTips.rst#unsafe-code +// - https://forums.swift.org/t/improving-linked-list-performance-swift-release-and-swift-retain-overhead/17205 + /// Implements a red-black tree for efficiently editing, storing and retrieving lines of text in a document. public final class TextLineStorage { private enum MetaFixupAction { @@ -75,26 +87,28 @@ public final class TextLineStorage { } insertedNode.color = .red - var currentNode = root + var currentNode: Unmanaged> = Unmanaged>.passUnretained(root!) + var shouldContinue = true var currentOffset: Int = root?.leftSubtreeOffset ?? 0 - while let node = currentNode { + while shouldContinue { + let node = currentNode.takeUnretainedValue() if currentOffset >= index { if node.left != nil { - currentNode = node.left + currentNode = Unmanaged>.passUnretained(node.left!) currentOffset = (currentOffset - node.leftSubtreeOffset) + (node.left?.leftSubtreeOffset ?? 0) } else { node.left = insertedNode insertedNode.parent = node - currentNode = nil + shouldContinue = false } } else { if node.right != nil { - currentNode = node.right + currentNode = Unmanaged>.passUnretained(node.right!) currentOffset += node.length + (node.right?.leftSubtreeOffset ?? 0) } else { node.right = insertedNode insertedNode.parent = node - currentNode = nil + shouldContinue = false } } } @@ -481,22 +495,30 @@ private extension TextLineStorage { nodeAction: MetaFixupAction = .none ) { 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 + var ref = Unmanaged>.passUnretained(node) + while let node = ref._withUnsafeGuaranteedRef({ $0.parent }), node !== root { + if node.left === ref.takeUnretainedValue() { + node.leftSubtreeOffset += delta + node.leftSubtreeHeight += deltaHeight switch nodeAction { case .inserted: - node?.parent?.leftSubtreeCount += 1 + node.leftSubtreeCount += 1 case .deleted: - node?.parent?.leftSubtreeCount -= 1 + node.leftSubtreeCount -= 1 case .none: - node = node?.parent - continue + if node.parent != nil { + ref = Unmanaged.passUnretained(node.parent!) + continue + } else { + return + } } } - node = node?.parent + if node.parent != nil { + ref = Unmanaged.passUnretained(node.parent!) + } else { + return + } } } } diff --git a/Sources/CodeEditTextView/Gutter/GutterView.swift b/Sources/CodeEditTextView/Gutter/GutterView.swift index 619403ce7..1cd5c8b03 100644 --- a/Sources/CodeEditTextView/Gutter/GutterView.swift +++ b/Sources/CodeEditTextView/Gutter/GutterView.swift @@ -56,6 +56,10 @@ public class GutterView: NSView { true } + override public var wantsDefaultClipping: Bool { + false + } + public init( font: NSFont, textColor: NSColor, @@ -68,6 +72,7 @@ public class GutterView: NSView { self.delegate = delegate super.init(frame: .zero) + clipsToBounds = false wantsLayer = true layerContentsRedrawPolicy = .onSetNeedsDisplay translatesAutoresizingMaskIntoConstraints = false @@ -187,6 +192,8 @@ public class GutterView: NSView { guard let context = NSGraphicsContext.current?.cgContext else { return } + superview?.clipsToBounds = false + superview?.layer?.masksToBounds = false layer?.backgroundColor = backgroundColor?.cgColor updateWidthIfNeeded() drawSelectedLines(context) diff --git a/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift b/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift index c04f3e6c7..7e7887b99 100644 --- a/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift +++ b/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift @@ -228,6 +228,8 @@ final class TextLayoutLineStorageTests: XCTestCase { } tree.build(from: lines, estimatedLineHeight: 1.0) // Measure time when inserting randomly into an already built tree. + // Start 0.667s + // 10/6/23 0.474s -30% measure { for _ in 0..<100_000 { tree.insert( @@ -245,6 +247,7 @@ final class TextLayoutLineStorageTests: XCTestCase { length: $0 + 1 ) } + // Start 0.113s measure { tree.build(from: lines, estimatedLineHeight: 1.0) } @@ -260,7 +263,7 @@ final class TextLayoutLineStorageTests: XCTestCase { )) } tree.build(from: lines, estimatedLineHeight: 1.0) - + // Start 0.181s measure { for line in tree { _ = line From 048e7b8080eb8feef5196ba6421829fe686fef07 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 7 Oct 2023 00:28:34 -0500 Subject: [PATCH 48/75] Fix TextLineStorage.update Test --- .../TextLineStorage/TextLineStorage+Node.swift | 14 +++++++------- .../TextLineStorage/TextLineStorage.swift | 15 +++++---------- Sources/CodeEditTextView/CodeEditTextView.swift | 1 - .../TextLayoutLineStorageTests.swift | 2 +- 4 files changed, 13 insertions(+), 19 deletions(-) diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift index 4db4534c2..18eab0599 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift @@ -8,11 +8,11 @@ import Foundation extension TextLineStorage { - func isRightChild(_ node: Node) -> Bool { + internal func isRightChild(_ node: Node) -> Bool { node.parent?.right === node } - func isLeftChild(_ node: Node) -> Bool { + internal func isLeftChild(_ node: Node) -> Bool { node.parent?.left === node } @@ -34,7 +34,7 @@ extension TextLineStorage { /// - Parameters: /// - nodeU: The node to replace. /// - nodeV: The node to insert in place of `nodeU` - func transplant(_ nodeU: Node, with nodeV: Node?) { + internal func transplant(_ nodeU: Node, with nodeV: Node?) { if nodeU.parent == nil { root = nodeV } else if isLeftChild(nodeU) { @@ -93,7 +93,7 @@ extension TextLineStorage { self.color = color } - func sibling() -> Node? { + internal func sibling() -> Node? { if parent?.left === self { return parent?.right } else { @@ -101,7 +101,7 @@ extension TextLineStorage { } } - func minimum() -> Node { + internal func minimum() -> Node { if let left { return left.minimum() } else { @@ -109,7 +109,7 @@ extension TextLineStorage { } } - func maximum() -> Node { + internal func maximum() -> Node { if let right { return right.maximum() } else { @@ -117,7 +117,7 @@ extension TextLineStorage { } } - func getSuccessor() -> Node? { + internal 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/CodeEditInputView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift index d49d9bd1b..ff63e20ed 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift @@ -11,11 +11,11 @@ import Foundation // Specifically, all rotation methods, fixup methods, and internal search methods must be kept private. // swiftlint:disable file_length -// There is a lot of ugly `Unmanaged` code in this class. This is due to the fact that Swift often has a hard time +// There is some ugly `Unmanaged` code in this class. This is due to the fact that Swift often has a hard time // optimizing retain/release calls for object trees. For instance, the `metaFixup` method has a lot of retain/release // calls to each node/parent as we do a little walk up the tree. // -// Using Unmanaged references resulted in a -30% decrease (0.667s -> 0.474s) in the +// Using Unmanaged references resulted in a -15% decrease (0.667s -> 0.563s) in the // TextLayoutLineStorageTests.test_insertPerformance benchmark when initially tested, and similar optimizations // have also since been implemented in `insert`. // @@ -496,7 +496,7 @@ private extension TextLineStorage { ) { guard node.parent != nil else { return } var ref = Unmanaged>.passUnretained(node) - while let node = ref._withUnsafeGuaranteedRef({ $0.parent }), node !== root { + while let node = ref._withUnsafeGuaranteedRef({ $0.parent }), ref.takeUnretainedValue() !== root { if node.left === ref.takeUnretainedValue() { node.leftSubtreeOffset += delta node.leftSubtreeHeight += deltaHeight @@ -506,16 +506,11 @@ private extension TextLineStorage { case .deleted: node.leftSubtreeCount -= 1 case .none: - if node.parent != nil { - ref = Unmanaged.passUnretained(node.parent!) - continue - } else { - return - } + break } } if node.parent != nil { - ref = Unmanaged.passUnretained(node.parent!) + ref = Unmanaged.passUnretained(node) } else { return } diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index d62780a6f..966fccd89 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditTextView/CodeEditTextView.swift @@ -9,7 +9,6 @@ import SwiftUI import CodeEditInputView import CodeEditLanguages -/// A `SwiftUI` wrapper for a ``STTextViewController``. public struct CodeEditTextView: NSViewControllerRepresentable { /// Initializes a Text Editor diff --git a/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift b/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift index 7e7887b99..96a7f6e2a 100644 --- a/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift +++ b/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift @@ -229,7 +229,7 @@ final class TextLayoutLineStorageTests: XCTestCase { tree.build(from: lines, estimatedLineHeight: 1.0) // Measure time when inserting randomly into an already built tree. // Start 0.667s - // 10/6/23 0.474s -30% + // 10/6/23 0.563s -15.59% measure { for _ in 0..<100_000 { tree.insert( From 5c389da8532682d8aa050264422b402fdc160ffd Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 7 Oct 2023 14:50:14 -0500 Subject: [PATCH 49/75] Fix Undo/Redo, Line Fragment Move, Lint Errors --- .../TextLineStorage+Node.swift | 12 +++ .../TextLineStorage/TextLineStorage.swift | 10 +-- ...lectionManager+SelectionManipulation.swift | 85 +++++++++++++------ .../TextSelectionManager.swift | 35 ++++---- .../TextView/TextView+Move.swift | 8 +- .../TextView/TextView+Setup.swift | 8 +- .../CodeEditInputView/TextView/TextView.swift | 28 ++---- .../Utils/CEUndoManager.swift | 12 +-- 8 files changed, 106 insertions(+), 92 deletions(-) diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift index 18eab0599..b3e332829 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift @@ -93,6 +93,18 @@ extension TextLineStorage { self.color = color } + convenience init(length: Int, data: NodeData, height: CGFloat) { + self.init( + length: length, + data: data, + leftSubtreeOffset: 0, + leftSubtreeHeight: 0.0, + leftSubtreeCount: 0, + height: height, + color: .black + ) + } + internal func sibling() -> Node? { if parent?.left === self { return parent?.right diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift index ff63e20ed..991845fee 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift @@ -72,15 +72,7 @@ public final class TextLineStorage { self.height += height } - let insertedNode = Node( - length: length, - data: line, - leftSubtreeOffset: 0, - leftSubtreeHeight: 0.0, - leftSubtreeCount: 0, - height: height, - color: .black - ) + let insertedNode = Node(length: length, data: line, height: height) guard root != nil else { root = insertedNode return diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift index cb8fa72c3..46903f9f9 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift @@ -88,6 +88,8 @@ public extension TextSelectionManager { return extendSelectionWord(string: string, from: offset, delta: delta) case .line, .container: return extendSelectionLine(string: string, from: offset, delta: delta) + case .visualLine: + return extendSelectionVisualLine(string: string, from: offset, delta: delta) case .document: if delta > 0 { return NSRange(location: offset, length: string.length - offset) @@ -123,11 +125,7 @@ public extension TextSelectionManager { return NSRange(location: 0, length: 0) } - if decomposeCharacters { - return range - } else { - return string.rangeOfComposedCharacterSequences(for: range) - } + return decomposeCharacters ? range : string.rangeOfComposedCharacterSequences(for: range) } /// Extends the selection by one "word". @@ -149,10 +147,7 @@ public extension TextSelectionManager { var hasFoundValidWordChar = false string.enumerateSubstrings( - in: NSRange( - location: delta > 0 ? offset : 0, - length: delta > 0 ? string.length - offset : offset - ), + in: NSRange(location: delta > 0 ? offset : 0, length: delta > 0 ? string.length - offset : offset), options: enumerationOptions ) { substring, _, _, stop in guard let substring = substring else { @@ -178,7 +173,7 @@ public extension TextSelectionManager { return rangeToDelete } - /// Extends the selection by one line in the direction specified. + /// Extends the selection by one visual line in the direction specified (eg one line fragment). /// /// 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. @@ -201,7 +196,7 @@ public extension TextSelectionManager { /// - 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 { + private func extendSelectionVisualLine(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 { @@ -214,6 +209,37 @@ public extension TextSelectionManager { ) : line.range.location + lineFragment.range.location + return _extendSelectionLine(string: string, lineBound: lineBound, offset: offset, delta: delta) + } + + /// Extends the selection by one real 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. + /// + /// - 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) else { + return NSRange(location: offset, length: 0) + } + let lineBound = delta > 0 + ? line.range.max - (layoutManager?.detectedLineEnding.length ?? 1) + : line.range.location + + return _extendSelectionLine(string: string, lineBound: lineBound, offset: offset, delta: delta) + } + + /// Common code for `extendSelectionLine` and `extendSelectionVisualLine` + private func _extendSelectionLine( + string: NSString, + lineBound: Int, + offset: Int, + delta: Int + ) -> NSRange { var foundRange = NSRange( location: min(lineBound, offset), length: max(lineBound, offset) - min(lineBound, offset) @@ -222,23 +248,34 @@ public extension TextSelectionManager { // 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 - .whitespacesAndNewlines.subtracting(.newlines) - .isSuperset(of: CharacterSet(charactersIn: substring)) { - foundRange.location += 1 - foundRange.length -= 1 - } else { - stop.pointee = true - } + foundRange = findBeginningOfLineText(string: string, initialRange: foundRange) + } + + return foundRange.length == 0 ? originalFoundRange : foundRange + } + + /// Finds the beginning of text in a line not including whitespace. + /// - Parameters: + /// - string: The string to look in. + /// - initialRange: The range to begin looking from. + /// - Returns: A new range to replace the given range for the line. + private func findBeginningOfLineText(string: NSString, initialRange: NSRange) -> NSRange { + var foundRange = initialRange + string.enumerateSubstrings(in: foundRange, options: .byCaretPositions) { substring, _, _, stop in + if let substring = substring as String? { + if CharacterSet + .whitespacesAndNewlines.subtracting(.newlines) + .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 + return foundRange } // MARK: - Vertical Methods @@ -259,7 +296,7 @@ public extension TextSelectionManager { switch destination { case .character: return extendSelectionVerticalCharacter(from: offset, up: up, suggestedXPos: suggestedXPos) - case .word, .line: + case .word, .line, .visualLine: return extendSelectionVerticalLine(from: offset, up: up) case .container: return extendSelectionContainer(from: offset, delta: up ? 1 : -1) diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift index db04202d9..8f214be9d 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift @@ -49,6 +49,7 @@ public class TextSelectionManager: NSObject { case character case word case line + case visualLine /// Eg: Bottom of screen case container case document @@ -207,6 +208,7 @@ public class TextSelectionManager: NSObject { context.restoreGState() } + // TODO: Move this drawing to `LineFragmentView` /// Draws a selected range in the given context. /// - Parameters: /// - rect: The rect to draw in. @@ -223,14 +225,12 @@ public class TextSelectionManager: NSObject { for linePosition in layoutManager.lineStorage.linesInRange(range) { if linePosition.range.intersection(range) == linePosition.range { // If the selected range contains the entire line - fillRects.append( - CGRect( - x: rect.minX, - y: linePosition.yPos, - width: rect.width, - height: linePosition.height - ) - ) + fillRects.append(CGRect( + x: rect.minX, + y: linePosition.yPos, + width: rect.width, + height: linePosition.height + )) } else { // The selected range contains some portion of the line for fragmentPosition in linePosition.data.lineFragments { @@ -256,14 +256,12 @@ public class TextSelectionManager: NSObject { continue } - fillRects.append( - CGRect( - x: minRect.origin.x, - y: minRect.origin.y, - width: maxRect.minX - minRect.minX, - height: max(minRect.height, maxRect.height) - ) - ) + fillRects.append(CGRect( + x: minRect.origin.x, + y: minRect.origin.y, + width: maxRect.minX - minRect.minX, + height: max(minRect.height, maxRect.height) + )) } } } @@ -271,10 +269,7 @@ public class TextSelectionManager: NSObject { let min = fillRects.min(by: { $0.origin.y < $1.origin.y })?.origin ?? .zero let max = fillRects.max(by: { $0.origin.y < $1.origin.y }) ?? .zero let size = CGSize(width: max.maxX - min.x, height: max.maxY - min.y) - textSelection.boundingRect = CGRect( - origin: min, - size: size - ) + textSelection.boundingRect = CGRect(origin: min, size: size) context.fill(fillRects) context.restoreGState() diff --git a/Sources/CodeEditInputView/TextView/TextView+Move.swift b/Sources/CodeEditInputView/TextView/TextView+Move.swift index d31c4a4e4..0af505636 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Move.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Move.swift @@ -82,25 +82,25 @@ extension TextView { /// Moves the cursors left to the end of the line. override public func moveToLeftEndOfLine(_ sender: Any?) { - selectionManager.moveSelections(direction: .backward, destination: .line) + selectionManager.moveSelections(direction: .backward, destination: .visualLine) scrollSelectionToVisible() } /// Moves the cursors left to the end of the line extending the current selection. override public func moveToLeftEndOfLineAndModifySelection(_ sender: Any?) { - selectionManager.moveSelections(direction: .backward, destination: .line, modifySelection: true) + selectionManager.moveSelections(direction: .backward, destination: .visualLine, modifySelection: true) scrollSelectionToVisible() } /// Moves the cursors right to the end of the line. override public func moveToRightEndOfLine(_ sender: Any?) { - selectionManager.moveSelections(direction: .forward, destination: .line) + selectionManager.moveSelections(direction: .forward, destination: .visualLine) scrollSelectionToVisible() } /// Moves the cursors right to the end of the line extending the current selection. override public func moveToRightEndOfLineAndModifySelection(_ sender: Any?) { - selectionManager.moveSelections(direction: .forward, destination: .line, modifySelection: true) + selectionManager.moveSelections(direction: .forward, destination: .visualLine, modifySelection: true) scrollSelectionToVisible() } diff --git a/Sources/CodeEditInputView/TextView/TextView+Setup.swift b/Sources/CodeEditInputView/TextView/TextView+Setup.swift index f833d41d8..85b8c9417 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Setup.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Setup.swift @@ -8,8 +8,8 @@ import AppKit extension TextView { - internal func setUpLayoutManager() { - layoutManager = TextLayoutManager( + internal func setUpLayoutManager() -> TextLayoutManager { + TextLayoutManager( textStorage: textStorage, typingAttributes: [ .font: font @@ -21,8 +21,8 @@ extension TextView { ) } - internal func setUpSelectionManager() { - selectionManager = TextSelectionManager( + internal func setUpSelectionManager() -> TextSelectionManager { + TextSelectionManager( layoutManager: layoutManager, textStorage: textStorage, layoutView: self, // TODO: This is an odd syntax... consider reworking this diff --git a/Sources/CodeEditInputView/TextView/TextView.swift b/Sources/CodeEditInputView/TextView/TextView.swift index 45742cf57..bd36a41e3 100644 --- a/Sources/CodeEditInputView/TextView/TextView.swift +++ b/Sources/CodeEditInputView/TextView/TextView.swift @@ -62,26 +62,8 @@ public class TextView: NSView, NSTextContent { 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) public var layoutManager: TextLayoutManager! + private(set) public var selectionManager: TextSelectionManager! // MARK: - Private Properties @@ -135,8 +117,10 @@ public class TextView: NSView, NSTextContent { textStorage.addAttributes([.font: font], range: documentRange) textStorage.delegate = storageDelegate - setUpLayoutManager() - setUpSelectionManager() + layoutManager = setUpLayoutManager() + storageDelegate.addDelegate(layoutManager) + selectionManager = setUpSelectionManager() + storageDelegate.addDelegate(selectionManager) _undoManager = CEUndoManager(textView: self) diff --git a/Sources/CodeEditInputView/Utils/CEUndoManager.swift b/Sources/CodeEditInputView/Utils/CEUndoManager.swift index d68f1bad7..ed26dbf3c 100644 --- a/Sources/CodeEditInputView/Utils/CEUndoManager.swift +++ b/Sources/CodeEditInputView/Utils/CEUndoManager.swift @@ -17,7 +17,7 @@ import TextStory /// If needed, the automatic undo grouping can be overridden using the `beginGrouping()` and `endGrouping()` methods. class CEUndoManager { /// An `UndoManager` subclass that forwards relevant actions to a `CEUndoManager`. - /// Allows for objects like `STTextView` to use the `UndoManager` API + /// Allows for objects like `TextView` to use the `UndoManager` API /// while CETV manages the undo/redo actions. class DelegatedUndoManager: UndoManager { weak var parent: CEUndoManager? @@ -86,11 +86,9 @@ class CEUndoManager { return } isUndoing = true - textView.textStorage.beginEditing() for mutation in item.mutations.reversed() { - textView.textStorage.applyMutation(mutation.inverse) + textView.insertText(mutation.inverse.string, replacementRange: mutation.inverse.range) } - textView.textStorage.endEditing() redoStack.append(item) isUndoing = false } @@ -101,11 +99,9 @@ class CEUndoManager { return } isRedoing = true - textView.textStorage.beginEditing() for mutation in item.mutations { - textView.textStorage.applyMutation(mutation.mutation) + textView.insertText(mutation.mutation.string, replacementRange: mutation.mutation.range) } - textView.textStorage.endEditing() undoStack.append(item) isRedoing = false } @@ -123,8 +119,6 @@ class CEUndoManager { public func registerMutation(_ mutation: TextMutation) { guard let textView, let textStorage = textView.textStorage, - mutation.range.length > 0, - !mutation.string.isEmpty, !isUndoing, !isRedoing else { return From d7050abb2ea020d1afb267513b6936f0ebf3257e Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 14 Oct 2023 15:49:16 -0500 Subject: [PATCH 50/75] Typing Attributes, Remove STTextView, Integrate Previous Features --- Package.resolved | 9 - Package.swift | 6 - .../TextLayoutManager+Public.swift | 9 + .../TextLayoutManager/TextLayoutManager.swift | 16 +- .../TextSelectionManager/CursorView.swift | 8 +- .../TextSelectionManager+Move.swift | 2 +- .../TextSelectionManager.swift | 24 +- .../TextView/TextView+ReplaceCharacters.swift | 5 +- .../TextView/TextView+Setup.swift | 6 +- .../CodeEditInputView/TextView/TextView.swift | 174 +++++- .../CodeEditTextView/CodeEditTextView.swift | 83 +-- .../STTextViewController+Cursor.swift | 109 ---- .../STTextViewController+Highlighter.swift | 55 -- .../STTextViewController+Lifecycle.swift | 124 ---- ...extViewController+STTextViewDelegate.swift | 43 -- .../STTextViewController+TextContainer.swift | 43 -- .../Controller/STTextViewController.swift | 266 --------- .../TextViewController+Cursor.swift | 48 ++ ...TextViewController+HighlightBracket.swift} | 49 +- .../TextViewController+Highlighter.swift | 52 ++ .../TextViewController+TextFormation.swift} | 10 +- .../Controller/TextViewController.swift | 366 ++++++++++++ .../Extensions/NSRange+/NSRange+isEmpty.swift | 14 + .../STTextView+/STTextView+AutoComplete.swift | 48 -- .../STTextView+ContentStorage.swift | 17 - .../STTextView+HighlighterTextView.swift | 23 - .../STTextView+TextInterface.swift | 57 -- .../STTextView+/STTextView+VisibleRange.swift | 26 - .../TextView+Menu.swift} | 4 +- .../TextView+/TextView+TextFormation.swift | 40 ++ .../Filters/DeleteWhitespaceFilter.swift | 8 +- .../CodeEditTextView/Gutter/GutterView.swift | 4 +- .../CodeEditTextView/TextViewController.swift | 231 -------- .../STTextViewControllerTests.swift | 556 +++++++++--------- 34 files changed, 1062 insertions(+), 1473 deletions(-) delete mode 100644 Sources/CodeEditTextView/Controller/STTextViewController+Cursor.swift delete mode 100644 Sources/CodeEditTextView/Controller/STTextViewController+Highlighter.swift delete mode 100644 Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift delete mode 100644 Sources/CodeEditTextView/Controller/STTextViewController+STTextViewDelegate.swift delete mode 100644 Sources/CodeEditTextView/Controller/STTextViewController+TextContainer.swift delete mode 100644 Sources/CodeEditTextView/Controller/STTextViewController.swift create mode 100644 Sources/CodeEditTextView/Controller/TextViewController+Cursor.swift rename Sources/CodeEditTextView/Controller/{STTextViewController+HighlightBracket.swift => TextViewController+HighlightBracket.swift} (81%) create mode 100644 Sources/CodeEditTextView/Controller/TextViewController+Highlighter.swift rename Sources/CodeEditTextView/{Filters/STTextViewController+TextFormation.swift => Controller/TextViewController+TextFormation.swift} (96%) create mode 100644 Sources/CodeEditTextView/Controller/TextViewController.swift create mode 100644 Sources/CodeEditTextView/Extensions/NSRange+/NSRange+isEmpty.swift delete mode 100644 Sources/CodeEditTextView/Extensions/STTextView+/STTextView+AutoComplete.swift delete mode 100644 Sources/CodeEditTextView/Extensions/STTextView+/STTextView+ContentStorage.swift delete mode 100644 Sources/CodeEditTextView/Extensions/STTextView+/STTextView+HighlighterTextView.swift delete mode 100644 Sources/CodeEditTextView/Extensions/STTextView+/STTextView+TextInterface.swift delete mode 100644 Sources/CodeEditTextView/Extensions/STTextView+/STTextView+VisibleRange.swift rename Sources/CodeEditTextView/Extensions/{STTextView+/STTextView+Menu.swift => TextView+/TextView+Menu.swift} (98%) create mode 100644 Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift delete mode 100644 Sources/CodeEditTextView/TextViewController.swift diff --git a/Package.resolved b/Package.resolved index 2bbf766f8..9dd636e30 100644 --- a/Package.resolved +++ b/Package.resolved @@ -18,15 +18,6 @@ "version" : "1.5.3" } }, - { - "identity" : "sttextview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/krzyzanowskim/STTextView.git", - "state" : { - "revision" : "1046965bd62dc05cf17beb4b4e901cdae851395e", - "version" : "0.8.7" - } - }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 44b5a67f2..cd2d34e88 100644 --- a/Package.swift +++ b/Package.swift @@ -13,10 +13,6 @@ let package = Package( ), ], dependencies: [ - .package( - url: "https://github.com/krzyzanowskim/STTextView.git", - exact: "0.8.7" - ), .package( url: "https://github.com/CodeEditApp/CodeEditLanguages.git", exact: "0.1.17" @@ -39,11 +35,9 @@ let package = Package( name: "CodeEditTextView", dependencies: [ "Common", - "STTextView", "CodeEditInputView", "CodeEditLanguages", "TextFormation", - .product(name: "STTextKitPlus", package: "STTextView") ], plugins: [ .plugin(name: "SwiftLint", package: "SwiftLintPlugin") diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift index 8d1414a49..238f0e0b2 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift @@ -24,6 +24,15 @@ extension TextLayoutManager { lineStorage.getLine(atIndex: offset) } + /// Finds text line and returns it if found. + /// Lines are 0 indexed. + /// - Parameter index: The line to find. + /// - Returns: The text line position if any, `nil` if the index is out of bounds. + public func textLineForIndex(_ index: Int) -> TextLineStorage.TextLinePosition? { + guard index > 0 && index < lineStorage.count else { return nil } + return lineStorage.getLine(atIndex: index) + } + public func textOffsetAtPoint(_ point: CGPoint) -> Int? { guard let position = lineStorage.getLine(atPosition: point.y), let fragmentPosition = position.data.typesetter.lineFragments.getLine( diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift index 8e80fac10..f95318244 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift @@ -24,8 +24,16 @@ public class TextLayoutManager: NSObject { public weak var delegate: TextLayoutManagerDelegate? public var typingAttributes: [NSAttributedString.Key: Any] - public var lineHeightMultiplier: CGFloat - public var wrapLines: Bool + public var lineHeightMultiplier: CGFloat { + didSet { + setNeedsLayout() + } + } + public var wrapLines: Bool { + didSet { + setNeedsLayout() + } + } public var detectedLineEnding: LineEnding = .lineFeed /// The edge insets to inset all text layout with. public var edgeInsets: HorizontalEdgeInsets = .zero { @@ -104,7 +112,7 @@ public class TextLayoutManager: NSObject { print("Text Layout Manager built in: ", TimeInterval(nanos) / TimeInterval(NSEC_PER_MSEC), "ms") } - internal func estimateLineHeight() -> CGFloat { + public func estimateLineHeight() -> CGFloat { let string = NSAttributedString(string: "0", attributes: typingAttributes) let typesetter = CTTypesetterCreateWithAttributedString(string) let ctLine = CTTypesetterCreateLine(typesetter, CFRangeMake(0, 1)) @@ -136,7 +144,7 @@ public class TextLayoutManager: NSObject { layoutLines() } - func setNeedsLayout() { + public func setNeedsLayout() { needsLayout = true visibleLineIds.removeAll(keepingCapacity: true) } diff --git a/Sources/CodeEditInputView/TextSelectionManager/CursorView.swift b/Sources/CodeEditInputView/TextSelectionManager/CursorView.swift index 8011d81b1..40ff5d876 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/CursorView.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/CursorView.swift @@ -9,10 +9,14 @@ import AppKit /// Animates a cursor. open class CursorView: NSView { + public var color: NSColor { + didSet { + layer?.backgroundColor = color.cgColor + } + } + private let blinkDuration: TimeInterval? - private let color: NSColor private let width: CGFloat - private var timer: Timer? open override var isFlipped: Bool { diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Move.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Move.swift index 61754a8a1..fa912970d 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Move.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Move.swift @@ -32,7 +32,7 @@ extension TextSelectionManager { } updateSelectionViews() delegate?.setNeedsDisplay() - NotificationCenter.default.post(Notification(name: TextSelectionManager.selectionChangedNotification)) + NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) } /// Moves a single selection determined by the direction and destination provided. diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift index 8f214be9d..f2b1b9159 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift @@ -68,6 +68,12 @@ public class TextSelectionManager: NSObject { Notification.Name("TextSelectionManager.TextSelectionChangedNotification") } + public var insertionPointColor: NSColor = NSColor.labelColor { + didSet { + textSelections.forEach { $0.view?.color = insertionPointColor } + } + } + public var highlightSelectedLine: Bool = true public var selectedLineBackgroundColor: NSColor = NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) public var selectionBackgroundColor: NSColor = NSColor.selectedTextBackgroundColor @@ -101,7 +107,7 @@ public class TextSelectionManager: NSObject { selection.suggestedXPos = layoutManager?.rectForOffset(range.location)?.minX textSelections = [selection] updateSelectionViews() - NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification)) + NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) } public func setSelectedRanges(_ ranges: [NSRange]) { @@ -112,7 +118,7 @@ public class TextSelectionManager: NSObject { return selection } updateSelectionViews() - NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification)) + NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) } // MARK: - Selection Views @@ -132,7 +138,7 @@ public class TextSelectionManager: NSObject { || textSelection.boundingRect.origin != cursorOrigin || textSelection.boundingRect.height != lineFragment?.data.scaledHeight ?? 0 { textSelection.view?.removeFromSuperview() - let cursorView = CursorView() + let cursorView = CursorView(color: insertionPointColor) cursorView.frame.origin = cursorOrigin cursorView.frame.size.height = lineFragment?.data.scaledHeight ?? 0 layoutView?.addSubview(cursorView) @@ -152,15 +158,6 @@ public class TextSelectionManager: NSObject { } } - /// 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() @@ -208,7 +205,6 @@ public class TextSelectionManager: NSObject { context.restoreGState() } - // TODO: Move this drawing to `LineFragmentView` /// Draws a selected range in the given context. /// - Parameters: /// - rect: The rect to draw in. @@ -313,6 +309,6 @@ extension TextSelectionManager: NSTextStorageDelegate { } } updateSelectionViews() - NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification)) + NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) } } diff --git a/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift b/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift index 0a5a691d2..40cdbfc29 100644 --- a/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift +++ b/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift @@ -29,7 +29,10 @@ extension TextView { _undoManager?.registerMutation( TextMutation(string: string as String, range: range, limit: textStorage.length) ) - textStorage.replaceCharacters(in: range, with: string) + textStorage.replaceCharacters( + in: range, + with: NSAttributedString(string: string, attributes: typingAttributes) + ) delegate?.textView(self, didReplaceContentsIn: range, with: string) } diff --git a/Sources/CodeEditInputView/TextView/TextView+Setup.swift b/Sources/CodeEditInputView/TextView/TextView+Setup.swift index 85b8c9417..4298f7f82 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Setup.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Setup.swift @@ -8,12 +8,10 @@ import AppKit extension TextView { - internal func setUpLayoutManager() -> TextLayoutManager { + internal func setUpLayoutManager(lineHeight: CGFloat, wrapLines: Bool) -> TextLayoutManager { TextLayoutManager( textStorage: textStorage, - typingAttributes: [ - .font: font - ], + typingAttributes: typingAttributes, lineHeightMultiplier: lineHeight, wrapLines: wrapLines, textView: self, // TODO: This is an odd syntax... consider reworking this diff --git a/Sources/CodeEditInputView/TextView/TextView.swift b/Sources/CodeEditInputView/TextView/TextView.swift index bd36a41e3..02451bce6 100644 --- a/Sources/CodeEditInputView/TextView/TextView.swift +++ b/Sources/CodeEditInputView/TextView/TextView.swift @@ -9,9 +9,12 @@ import AppKit import Common import TextStory -/** +// Disabling file length and type body length as the methods and variables contained in this file cannot be moved +// to extensions without a lot of work. +// swiftlint:disable type_body_length -``` +/** + ``` TextView |-> TextLayoutManager Creates, manages, and lays out text lines from a line storage | |-> [TextLine] Represents a text line @@ -24,44 +27,136 @@ import TextStory ``` */ public class TextView: NSView, NSTextContent { + // MARK: - Statics + + /// The default typing attributes. Defaults to: + /// - font: System font, size 12 + /// - foregroundColor: System text color + /// - kern: 0.0 + static public var defaultTypingAttributes: [NSAttributedString.Key: Any] { + [.font: NSFont.systemFont(ofSize: 12), .foregroundColor: NSColor.textColor, .kern: 0.0] + } + // MARK: - Configuration - func setString(_ string: String) { - textStorage.setAttributedString(.init(string: string)) + public var string: String { + get { + textStorage.string + } + set { + textStorage.setAttributedString(NSAttributedString(string: newValue, attributes: typingAttributes)) + } + } + + /// The attributes to apply to inserted text. + public var typingAttributes: [NSAttributedString.Key: Any] = [:] { + didSet { + setNeedsDisplay() + layoutManager?.setNeedsLayout() + } } + /// The default font of the text view. public var font: NSFont { + get { + (typingAttributes[.font] as? NSFont) ?? NSFont.systemFont(ofSize: 12) + } + set { + typingAttributes[.font] = newValue + } + } + + /// The text color of the text view. + public var textColor: NSColor { + get { + (typingAttributes[.foregroundColor] as? NSColor) ?? NSColor.textColor + } + set { + typingAttributes[.foregroundColor] = newValue + } + } + + /// The line height as a multiple of the font's line height. 1.0 represents no change in height. + public var lineHeight: CGFloat { + get { + layoutManager?.lineHeightMultiplier ?? 1.0 + } + set { + layoutManager?.lineHeightMultiplier = newValue + } + } + + /// Whether or not the editor should wrap lines + public var wrapLines: Bool { + get { + layoutManager?.wrapLines ?? false + } + set { + layoutManager?.wrapLines = newValue + } + } + public var editorOverscroll: CGFloat { didSet { setNeedsDisplay() + updateFrameIfNeeded() + } + } + + /// A multiplier that determines the amount of space between characters. `1.0` indicates no space, + /// `2.0` indicates one character of space between other characters. + public var letterSpacing: Double { + didSet { + kern = fontCharWidth * (letterSpacing - 1.0) layoutManager.setNeedsLayout() } } - public var lineHeight: CGFloat - public var wrapLines: Bool - public var editorOverscroll: CGFloat - public var isEditable: Bool - @Invalidating(.display) - public var isSelectable: Bool = true - public var letterSpacing: Double - public var edgeInsets: HorizontalEdgeInsets = .zero { + + public var isEditable: Bool { didSet { - layoutManager.edgeInsets = edgeInsets + setNeedsDisplay() selectionManager.updateSelectionViews() + if !isEditable && isFirstResponder { + _ = resignFirstResponder() + } } } - open var contentType: NSTextContentType? + public var isSelectable: Bool = true { + didSet { + if !isSelectable { + selectionManager.removeCursors() + if isFirstResponder { + _ = resignFirstResponder() + } + } + setNeedsDisplay() + } + } - public weak var delegate: TextViewDelegate? + public var edgeInsets: HorizontalEdgeInsets { + get { + layoutManager?.edgeInsets ?? .zero + } + set { + layoutManager?.edgeInsets = newValue + } + } - public var textStorage: NSTextStorage! { - didSet { - setUpLayoutManager() - setUpSelectionManager() - needsDisplay = true - needsLayout = true + /// The kern to use for characters. Defaults to `0.0` and is updated when `letterSpacing` is set. + public var kern: CGFloat { + get { + typingAttributes[.kern] as? CGFloat ?? 0 + } + set { + typingAttributes[.kern] = newValue } } + + open var contentType: NSTextContentType? + + public weak var delegate: TextViewDelegate? + + private(set) public var textStorage: NSTextStorage! private(set) public var layoutManager: TextLayoutManager! private(set) public var selectionManager: TextSelectionManager! @@ -71,6 +166,10 @@ public class TextView: NSView, NSTextContent { internal var mouseDragAnchor: CGPoint? internal var mouseDragTimer: Timer? + private var fontCharWidth: CGFloat { + (" " as NSString).size(withAttributes: [.font: font]).width + } + var _undoManager: CEUndoManager? @objc dynamic open var allowsUndo: Bool @@ -86,6 +185,7 @@ public class TextView: NSView, NSTextContent { public init( string: String, font: NSFont, + textColor: NSColor, lineHeight: CGFloat, wrapLines: Bool, editorOverscroll: CGFloat, @@ -98,9 +198,6 @@ public class TextView: NSView, NSTextContent { self.textStorage = NSTextStorage(string: string) self.storageDelegate = storageDelegate - self.font = font - self.lineHeight = lineHeight - self.wrapLines = wrapLines self.editorOverscroll = editorOverscroll self.isEditable = isEditable self.letterSpacing = letterSpacing @@ -113,11 +210,15 @@ public class TextView: NSView, NSTextContent { postsBoundsChangedNotifications = true autoresizingMask = [.width, .height] - // TODO: Implement typing/default attributes - textStorage.addAttributes([.font: font], range: documentRange) + self.typingAttributes = [ + .font: font, + .foregroundColor: textColor, + ] + + textStorage.addAttributes(typingAttributes, range: documentRange) textStorage.delegate = storageDelegate - layoutManager = setUpLayoutManager() + layoutManager = setUpLayoutManager(lineHeight: lineHeight, wrapLines: wrapLines) storageDelegate.addDelegate(layoutManager) selectionManager = setUpSelectionManager() storageDelegate.addDelegate(selectionManager) @@ -127,6 +228,20 @@ public class TextView: NSView, NSTextContent { layoutManager.layoutLines() } + /// Set a new text storage object for the view. + /// - Parameter textStorage: The new text storage to use. + public func setTextStorage(_ textStorage: NSTextStorage) { + let lineHeight = layoutManager.lineHeightMultiplier + let wrapLines = layoutManager.wrapLines + layoutManager = setUpLayoutManager(lineHeight: lineHeight, wrapLines: wrapLines) + storageDelegate.addDelegate(layoutManager) + selectionManager = setUpSelectionManager() + storageDelegate.addDelegate(selectionManager) + _undoManager?.clearStack() + needsDisplay = true + needsLayout = true + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -339,10 +454,13 @@ public class TextView: NSView, NSTextContent { extension TextView: TextSelectionManagerDelegate { public func setNeedsDisplay() { - self.setNeedsDisplay(visibleRect) + self.setNeedsDisplay(frame) } public func estimatedLineHeight() -> CGFloat { layoutManager.estimateLineHeight() } } + +// swiftlint:enable type_body_length +// swiftlint:disable:this file_length diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index 966fccd89..50edb99be 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditTextView/CodeEditTextView.swift @@ -21,7 +21,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { /// - indentOption: The behavior to use when the tab key is pressed. Defaults to 4 spaces. /// - lineHeight: The line height multiplier (e.g. `1.2`) /// - wrapLines: Whether lines wrap to the width of the editor - /// - editorOverscroll: The percentage for overscroll, between 0-1 (default: `0.0`) + /// - editorOverscroll: The distance to overscroll the editor by. /// - cursorPosition: The cursor's position in the editor, measured in `(lineNum, columnNum)` /// - useThemeBackground: Determines whether the editor uses the theme's background color, or a transparent /// background color @@ -43,7 +43,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { indentOption: IndentOption = .spaces(count: 4), lineHeight: Double, wrapLines: Bool, - editorOverscroll: Double = 0.0, + editorOverscroll: CGFloat = 0, cursorPosition: Binding<(Int, Int)>, useThemeBackground: Bool = true, highlightProvider: HighlightProviding? = nil, @@ -78,7 +78,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { private var indentOption: IndentOption private var lineHeight: Double private var wrapLines: Bool - private var editorOverscroll: Double + private var editorOverscroll: CGFloat @Binding private var cursorPosition: (Int, Int) private var useThemeBackground: Bool private var highlightProvider: HighlightProviding? @@ -113,51 +113,54 @@ public struct CodeEditTextView: NSViewControllerRepresentable { 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 -// } -// + 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 -// + controller.wrapLines = wrapLines + controller.useThemeBackground = useThemeBackground + controller.lineHeightMultiple = lineHeight + controller.editorOverscroll = editorOverscroll + controller.contentInsets = contentInsets + controller.bracketPairHighlight = bracketPairHighlight + if controller.isEditable != isEditable { + controller.isEditable = isEditable + } + // 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() + 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 { - true -// controller.font == font && -// controller.wrapLines == wrapLines && -// controller.useThemeBackground == useThemeBackground && -// controller.lineHeightMultiple == lineHeight && -// controller.editorOverscroll == editorOverscroll && -// controller.contentInsets == contentInsets && + controller.font == font && + controller.isEditable == isEditable && + 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 + controller.theme == theme && + controller.indentOption == indentOption && + controller.tabWidth == tabWidth && + controller.letterSpacing == letterSpacing && + controller.bracketPairHighlight == bracketPairHighlight } } diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+Cursor.swift b/Sources/CodeEditTextView/Controller/STTextViewController+Cursor.swift deleted file mode 100644 index eb50be6d8..000000000 --- a/Sources/CodeEditTextView/Controller/STTextViewController+Cursor.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// STTextViewController+Cursor.swift -// -// -// Created by Elias Wahl on 15.03.23. -// - -import Foundation -import AppKit - -extension STTextViewController { - func setCursorPosition(_ position: (Int, Int)) { - guard let provider = textView.textLayoutManager.textContentManager else { - return - } - - var (line, column) = position - let string = textView.string - if line > 0 { - if string.isEmpty { - // If the file is blank, automatically place the cursor in the first index. - let range = NSRange(string.startIndex.. Bool - in - var col = 1 - /// If the cursor is at the end of the document: - if textLayoutManager.offset(from: insertionPointLocation, to: documentEndLocation) == 0 { - /// If document is empty: - if textLayoutManager.offset(from: documentStartLocation, to: documentEndLocation) == 0 { - self.cursorPosition.wrappedValue = (1, 1) - return false - } - guard let cursorTextFragment = textLayoutManager.textLayoutFragment(for: textSegmentFrame.origin), - let cursorTextLineFragment = cursorTextFragment.textLineFragments.last - else { return false } - - col = cursorTextLineFragment.characterRange.length + 1 - if col == 1 { line += 1 } - } else { - guard let cursorTextLineFragment = textLayoutManager.textLineFragment(at: insertionPointLocation) - else { return false } - - /// +1, because we start with the first character with 1 - let tempCol = cursorTextLineFragment.characterIndex(for: textSegmentFrame.origin) - let result = tempCol.addingReportingOverflow(1) - - if !result.overflow { col = result.partialValue } - /// If cursor is at end of line add 1: - if cursorTextLineFragment.characterRange.length != 1 && - (cursorTextLineFragment.typographicBounds.width == (textSegmentFrame.maxX + 5.0)) { - col += 1 - } - - /// If cursor is at first character of line, the current line is not being included - if col == 1 { line += 1 } - } - - DispatchQueue.main.async { - self.cursorPosition.wrappedValue = (line, col) - } - return false - } - } -} diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+Highlighter.swift b/Sources/CodeEditTextView/Controller/STTextViewController+Highlighter.swift deleted file mode 100644 index 88812cdbf..000000000 --- a/Sources/CodeEditTextView/Controller/STTextViewController+Highlighter.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// STTextViewController+Highlighter.swift -// -// -// Created by Khan Winter on 4/21/23. -// - -import AppKit -import SwiftTreeSitter - -extension STTextViewController { - /// Configures the `Highlighter` object - internal func setUpHighlighter() { -// 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. - 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.textContentStorage?.textStorage?.mutableString.substring(with: range) - } - - provider = TreeSitterClient(textProvider: textProvider) - } - - if let provider = provider { - self.highlightProvider = provider - highlighter?.setHighlightProvider(provider) - } - } - - /// Gets all attributes for the given capture including the line height, background color, and text color. - /// - Parameter capture: The capture to use for syntax highlighting. - /// - Returns: All attributes to be applied. - public func attributesFor(_ capture: CaptureName?) -> [NSAttributedString.Key: Any] { - return [ - .font: font, - .foregroundColor: theme.colorFor(capture), - .baselineOffset: baselineOffset, - .paragraphStyle: paragraphStyle, - .kern: kern - ] - } -} diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift b/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift deleted file mode 100644 index cb8edcad0..000000000 --- a/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// STTextViewController+Lifecycle.swift -// CodeEditTextView -// -// Created by Khan Winter on 5/3/23. -// - -import AppKit -import STTextView - -extension STTextViewController { -// override public 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) -// } - - override public func viewDidLoad() { - super.viewDidLoad() - - NotificationCenter.default.addObserver(forName: NSWindow.didResizeNotification, - object: nil, - queue: .main) { [weak self] _ in - guard let self = self else { return } - (self.view as? NSScrollView)?.contentView.contentInsets.bottom = self.bottomContentInsets - self.updateTextContainerWidthIfNeeded() - } - - NotificationCenter.default.addObserver( - forName: STTextView.didChangeSelectionNotification, - object: nil, - queue: .main - ) { [weak self] _ in - let textSelections = self?.textView.textLayoutManager.textSelections.flatMap(\.textRanges) - guard self?.lastTextSelections != textSelections else { - return - } - self?.lastTextSelections = textSelections ?? [] - - self?.updateCursorPosition() - self?.highlightSelectionPairs() - } - - NotificationCenter.default.addObserver( - forName: NSView.frameDidChangeNotification, - object: (self.view as? NSScrollView)?.verticalRulerView, - queue: .main - ) { [weak self] _ in - self?.updateTextContainerWidthIfNeeded() - if self?.bracketPairHighlight == .flash { - self?.removeHighlightLayers() - } - } - - systemAppearance = NSApp.effectiveAppearance.name - - NSApp.publisher(for: \.effectiveAppearance) - .receive(on: RunLoop.main) - .sink { [weak self] newValue in - guard let self = self else { return } - - if self.systemAppearance != newValue.name { - self.systemAppearance = newValue.name - } - } - .store(in: &cancellables) - } - - override public func viewWillAppear() { - super.viewWillAppear() - updateTextContainerWidthIfNeeded(true) - } -} diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+STTextViewDelegate.swift b/Sources/CodeEditTextView/Controller/STTextViewController+STTextViewDelegate.swift deleted file mode 100644 index 2a0b41ec3..000000000 --- a/Sources/CodeEditTextView/Controller/STTextViewController+STTextViewDelegate.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// STTextViewController+STTextViewDelegate.swift -// CodeEditTextView -// -// Created by Khan Winter on 7/8/23. -// - -import AppKit -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 -// } -} diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+TextContainer.swift b/Sources/CodeEditTextView/Controller/STTextViewController+TextContainer.swift deleted file mode 100644 index 2b007921a..000000000 --- a/Sources/CodeEditTextView/Controller/STTextViewController+TextContainer.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// STTextViewController+TextContainer.swift -// -// -// Created by Khan Winter on 4/21/23. -// - -import AppKit -import STTextView - -extension STTextViewController { - /// Update the text view's text container if needed. - /// - /// Effectively updates the container to reflect the `wrapLines` setting, and to reflect any updates to the ruler, - /// scroll view, or window frames. - internal func updateTextContainerWidthIfNeeded(_ forceUpdate: Bool = false) { - let previousTrackingSetting = textView.widthTracksTextView - textView.widthTracksTextView = wrapLines - guard let scrollView = view as? NSScrollView else { - return - } - - if wrapLines { - var proposedSize = scrollView.contentSize - proposedSize.height = .greatestFiniteMagnitude - - if textView.textContainer.size != proposedSize || textView.frame.size != proposedSize || forceUpdate { - textView.textContainer.size = proposedSize - textView.setFrameSize(proposedSize) - } - } else { - var proposedSize = textView.frame.size - proposedSize.width = scrollView.contentSize.width - if previousTrackingSetting != wrapLines || forceUpdate { - textView.textContainer.size = CGSize( - width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude - ) - textView.setFrameSize(proposedSize) - textView.textLayoutManager.textViewportLayoutController.layoutViewport() - } - } - } -} diff --git a/Sources/CodeEditTextView/Controller/STTextViewController.swift b/Sources/CodeEditTextView/Controller/STTextViewController.swift deleted file mode 100644 index ad4a77390..000000000 --- a/Sources/CodeEditTextView/Controller/STTextViewController.swift +++ /dev/null @@ -1,266 +0,0 @@ -// -// STTextViewController.swift -// CodeEditTextView -// -// Created by Lukas Pistrol on 24.05.22. -// - -import AppKit -import SwiftUI -import Combine -import STTextView -import CodeEditLanguages -import TextFormation -import TextStory - -/// A View Controller managing and displaying a `STTextView` -public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAttributesProviding { - - internal var textView: STTextView! - - internal var rulerView: STLineNumberRulerView! - - /// Internal reference to any injected layers in the text view. - internal var highlightLayers: [CALayer] = [] - - /// Tracks the last text selections. Used to debounce `STTextView.didChangeSelectionNotification` being sent twice - /// for every new selection. - internal var lastTextSelections: [NSTextRange] = [] - -// internal var textViewUndoManager: CEUndoManager! - - /// Binding for the `textView`s string - public var text: Binding - - /// The associated `CodeLanguage` - public var language: CodeLanguage { didSet { - // TODO: Decide how to handle errors thrown here - highlighter?.setLanguage(language: language) - }} - - /// The associated `Theme` used for highlighting. - public var theme: EditorTheme { didSet { - highlighter?.invalidate() - }} - - /// Whether the code editor should use the theme background color or be transparent - public var useThemeBackground: Bool - - public var systemAppearance: NSAppearance.Name? - - var cancellables = Set() - - /// The visual width of tab characters in the text view measured in number of spaces. - public var tabWidth: Int { - didSet { - paragraphStyle = generateParagraphStyle() - reloadUI() - } - } - - /// The behavior to use when the tab key is pressed. - public var indentOption: IndentOption { - didSet { - setUpTextFormation() - } - } - - /// A multiplier for setting the line height. Defaults to `1.0` - public var lineHeightMultiple: Double = 1.0 - - /// The font to use in the `textView` - public var font: NSFont - - /// The current cursor position e.g. (1, 1) - public var cursorPosition: Binding<(Int, Int)> - - /// The editorOverscroll to use for the textView over scroll - public var editorOverscroll: Double - - /// Whether lines wrap to the width of the editor - public var wrapLines: Bool - - /// Whether or not text view is editable by user - public var isEditable: Bool - - /// Filters used when applying edits.. - internal var textFilters: [TextFormation.Filter] = [] - - /// Optional insets to offset the text view in the scroll view by. - public var contentInsets: NSEdgeInsets? - - /// A multiplier that determines the amount of space between characters. `1.0` indicates no space, - /// `2.0` indicates one character of space between other characters. - public var letterSpacing: Double = 1.0 { - didSet { - kern = fontCharWidth * (letterSpacing - 1.0) - reloadUI() - } - } - - /// The type of highlight to use when highlighting bracket pairs. Leave as `nil` to disable highlighting. - public var bracketPairHighlight: BracketPairHighlight? - - /// The kern to use for characters. Defaults to `0.0` and is updated when `letterSpacing` is set. - internal var kern: CGFloat = 0.0 - - private var fontCharWidth: CGFloat { - (" " as NSString).size(withAttributes: [.font: font]).width - } - - // MARK: - Highlighting - - internal var highlighter: Highlighter? - - /// The provided highlight provider. - internal var highlightProvider: HighlightProviding? - - // MARK: Init - - public init( - text: Binding, - language: CodeLanguage, - font: NSFont, - theme: EditorTheme, - tabWidth: Int, - indentOption: IndentOption, - lineHeight: Double, - wrapLines: Bool, - cursorPosition: Binding<(Int, Int)>, - editorOverscroll: Double, - useThemeBackground: Bool, - highlightProvider: HighlightProviding? = nil, - contentInsets: NSEdgeInsets? = nil, - isEditable: Bool, - letterSpacing: Double, - bracketPairHighlight: BracketPairHighlight? = nil - ) { - self.text = text - self.language = language - self.font = font - self.theme = theme - self.tabWidth = tabWidth - self.indentOption = indentOption - self.lineHeightMultiple = lineHeight - self.wrapLines = wrapLines - self.cursorPosition = cursorPosition - self.editorOverscroll = editorOverscroll - self.useThemeBackground = useThemeBackground - self.highlightProvider = highlightProvider - self.contentInsets = contentInsets - self.isEditable = isEditable - self.bracketPairHighlight = bracketPairHighlight - super.init(nibName: nil, bundle: nil) - } - - required init(coder: NSCoder) { - fatalError() - } - - public func textViewDidChangeText(_ notification: Notification) { - self.text.wrappedValue = textView.string - } - - // MARK: UI - - /// A default `NSParagraphStyle` with a set `lineHeight` - internal lazy var paragraphStyle: NSMutableParagraphStyle = generateParagraphStyle() - - private func generateParagraphStyle() -> NSMutableParagraphStyle { - // swiftlint:disable:next force_cast - let paragraph = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle - paragraph.minimumLineHeight = lineHeight - paragraph.maximumLineHeight = lineHeight - paragraph.tabStops.removeAll() - paragraph.defaultTabInterval = CGFloat(tabWidth) * fontCharWidth - return paragraph - } - - /// ScrollView's bottom inset using as editor overscroll - internal var bottomContentInsets: CGFloat { - let height = view.frame.height - var inset = editorOverscroll * height - - if height - inset < lineHeight { - inset = height - lineHeight - } - - return max(inset, .zero) - } - - /// Reloads the UI to apply changes to ``STTextViewController/font``, ``STTextViewController/theme``, ... - internal func reloadUI() { - textView.textColor = theme.text - textView.insertionPointColor = theme.insertionPoint - textView.selectionBackgroundColor = theme.selection - textView.selectedLineHighlightColor = useThemeBackground ? theme.lineHighlight : systemAppearance == .darkAqua - ? NSColor.quaternaryLabelColor - : NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) - textView.isEditable = isEditable - textView.highlightSelectedLine = isEditable - textView.typingAttributes = attributesFor(nil) - paragraphStyle = generateParagraphStyle() - textView.typingAttributes = attributesFor(nil) - - rulerView.selectedLineHighlightColor = useThemeBackground ? theme.lineHighlight : systemAppearance == .darkAqua - ? NSColor.quaternaryLabelColor - : NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) - rulerView.baselineOffset = baselineOffset - rulerView.highlightSelectedLine = isEditable - rulerView.rulerInsets = STRulerInsets(leading: 12, trailing: 8) - rulerView.font = font.rulerFont - rulerView.backgroundColor = theme.background - rulerView.ruleThickness = max( - NSString(string: "1000").size(withAttributes: [.font: font.rulerFont]).width - + rulerView.rulerInsets.leading - + rulerView.rulerInsets.trailing, - rulerView.ruleThickness - ) - if self.isEditable == false { - rulerView.selectedLineTextColor = nil - rulerView.selectedLineHighlightColor = .clear - } - - if let scrollView = view as? NSScrollView { - scrollView.drawsBackground = useThemeBackground - scrollView.backgroundColor = useThemeBackground ? theme.background : .clear - if let contentInsets = contentInsets { - scrollView.contentInsets = contentInsets - } - scrollView.contentInsets.bottom = bottomContentInsets + (contentInsets?.bottom ?? 0) - } - - highlighter?.invalidate() - updateTextContainerWidthIfNeeded() - highlightSelectionPairs() - } - - /// Calculated line height depending on ``STTextViewController/lineHeightMultiple`` - internal var lineHeight: Double { - font.lineHeight * lineHeightMultiple - } - - /// Calculated baseline offset depending on `lineHeight`. - internal var baselineOffset: Double { - ((self.lineHeight) - font.lineHeight) / 2 + 2 - } - - // MARK: Selectors - - override public func keyDown(with event: NSEvent) { - if bracketPairHighlight == .flash { - removeHighlightLayers() - } - } - - override public func insertTab(_ sender: Any?) { - textView.insertText("\t", replacementRange: textView.selectedRange) - } - - deinit { - removeHighlightLayers() - textView = nil - highlighter = nil - cancellables.forEach { $0.cancel() } - } -} diff --git a/Sources/CodeEditTextView/Controller/TextViewController+Cursor.swift b/Sources/CodeEditTextView/Controller/TextViewController+Cursor.swift new file mode 100644 index 000000000..0d5a1371d --- /dev/null +++ b/Sources/CodeEditTextView/Controller/TextViewController+Cursor.swift @@ -0,0 +1,48 @@ +// +// TextViewController+Cursor.swift +// +// +// Created by Elias Wahl on 15.03.23. +// + +import Foundation +import AppKit + +extension TextViewController { + /// Sets a new cursor position. + /// - Parameter position: The position to set. Lines and columns are 1-indexed. + func setCursorPosition(_ position: (Int, Int)) { + let (line, column) = position + guard line >= 0 && column >= 0 else { return } + + if textView.textStorage.length == 0 { + // If the file is blank, automatically place the cursor in the first index. + let range = NSRange(location: 0, length: 0) + _ = self.textView.becomeFirstResponder() + self.textView.selectionManager.setSelectedRange(range) + } else if line - 1 >= 0, let linePosition = textView.layoutManager.textLineForIndex(line - 1) { + // If this is a valid line, set the new position + let index = max( + linePosition.range.lowerBound, + min(linePosition.range.upperBound, column - 1) + ) + self.textView.selectionManager.setSelectedRange(NSRange(location: index, length: 0)) + } + } + + func updateCursorPosition() { + // Get the smallest cursor position. + guard let selectedRange = textView + .selectionManager + .textSelections + .sorted(by: { $0.range.lowerBound < $1.range.lowerBound}) + .first else { + return + } + + // Get the line it's in + guard let linePosition = textView.layoutManager.textLineForOffset(selectedRange.range.location) else { return } + let column = selectedRange.range.location - linePosition.range.location + cursorPosition.wrappedValue = (linePosition.index + 1, column + 1) + } +} diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+HighlightBracket.swift b/Sources/CodeEditTextView/Controller/TextViewController+HighlightBracket.swift similarity index 81% rename from Sources/CodeEditTextView/Controller/STTextViewController+HighlightBracket.swift rename to Sources/CodeEditTextView/Controller/TextViewController+HighlightBracket.swift index fb7a26c32..c545baa36 100644 --- a/Sources/CodeEditTextView/Controller/STTextViewController+HighlightBracket.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController+HighlightBracket.swift @@ -1,24 +1,22 @@ // -// STTextViewController+HighlightRange.swift +// TextViewController+HighlightRange.swift // CodeEditTextView // // Created by Khan Winter on 4/26/23. // import AppKit -import STTextView -extension STTextViewController { +extension TextViewController { /// Highlights bracket pairs using the current selection. internal func highlightSelectionPairs() { guard bracketPairHighlight != nil else { return } removeHighlightLayers() - for selection in textView.textLayoutManager.textSelections.flatMap(\.textRanges) { - if selection.isEmpty, - let range = selection.nsRange(using: textView.textContentManager), + for range in textView.selectionManager.textSelections.map({ $0.range }) { + if range.isEmpty, range.location > 0, // Range is not the beginning of the document - let preceedingCharacter = textView.textContentStorage?.textStorage?.substring( - from: NSRange(location: range.location - 1, length: 1) // The preceeding character exists + let preceedingCharacter = textView.textStorage.substring( + from: NSRange(location: range.location - 1, length: 1) // The preceding character exists ) { for pair in BracketPairs.allValues { if preceedingCharacter == pair.0 { @@ -31,9 +29,9 @@ extension STTextViewController { NSMaxRange(textView.documentRange)), reverse: false ) { - highlightRange(NSRange(location: characterIndex, length: 1)) + highlightCharacter(characterIndex) if bracketPairHighlight?.highlightsSourceBracket ?? false { - highlightRange(NSRange(location: range.location - 1, length: 1)) + highlightCharacter(range.location - 1) } } } else if preceedingCharacter == pair.1 && range.location - 1 > 0 { @@ -46,9 +44,9 @@ extension STTextViewController { textView.documentRange.location), reverse: true ) { - highlightRange(NSRange(location: characterIndex, length: 1)) + highlightCharacter(characterIndex) if bracketPairHighlight?.highlightsSourceBracket ?? false { - highlightRange(NSRange(location: range.location - 1, length: 1)) + highlightCharacter(range.location - 1) } } } @@ -82,7 +80,7 @@ extension STTextViewController { } var closeCount = 0 var index: Int? - textView.textContentStorage?.textStorage?.mutableString.enumerateSubstrings( + textView.textStorage.mutableString.enumerateSubstrings( in: reverse ? NSRange(location: limit, length: from - limit) : NSRange(location: from, length: limit - from), @@ -103,17 +101,16 @@ extension STTextViewController { return index } - /// Adds a temporary highlight effect to the given range. + /// Adds a temporary highlight effect to the character at the given location. /// - Parameters: - /// - range: The range to highlight + /// - location: The location of the character to highlight /// - scrollToRange: Set to true to scroll to the given range when highlighting. Defaults to `false`. - private func highlightRange(_ range: NSTextRange, scrollToRange: Bool = false) { + private func highlightCharacter(_ location: Int, scrollToRange: Bool = false) { guard let bracketPairHighlight = bracketPairHighlight, - var rectToHighlight = textView.textLayoutManager.textSegmentFrame( - in: range, type: .highlight - ) else { + var rectToHighlight = textView.layoutManager.rectForOffset(location) else { return } + print(rectToHighlight) let layer = CAShapeLayer() switch bracketPairHighlight { @@ -145,7 +142,7 @@ extension STTextViewController { layer.frame = rectToHighlight case .underline: let path = CGMutablePath() - let pathY = rectToHighlight.maxY - (lineHeight - font.lineHeight)/4 + let pathY = rectToHighlight.maxY - (rectToHighlight.height * (lineHeightMultiple - 1))/4 path.move(to: CGPoint(x: rectToHighlight.minX, y: pathY)) path.addLine(to: CGPoint(x: rectToHighlight.maxX, y: pathY)) layer.path = path @@ -209,18 +206,6 @@ extension STTextViewController { CATransaction.commit() } - /// Adds a temporary highlight effect to the given range. - /// - Parameters: - /// - range: The range to highlight - /// - scrollToRange: Set to true to scroll to the given range when highlighting. Defaults to `false`. - public func highlightRange(_ range: NSRange, scrollToRange: Bool = false) { - guard let textRange = NSTextRange(range, provider: textView.textContentManager) else { - return - } - - highlightRange(textRange, scrollToRange: scrollToRange) - } - /// Safely removes all highlight layers. internal func removeHighlightLayers() { highlightLayers.forEach { layer in diff --git a/Sources/CodeEditTextView/Controller/TextViewController+Highlighter.swift b/Sources/CodeEditTextView/Controller/TextViewController+Highlighter.swift new file mode 100644 index 000000000..9a5ba648b --- /dev/null +++ b/Sources/CodeEditTextView/Controller/TextViewController+Highlighter.swift @@ -0,0 +1,52 @@ +// +// TextViewController+Highlighter.swift +// +// +// Created by Khan Winter on 10/14/23. +// + +import Foundation +import SwiftTreeSitter + +extension TextViewController { + internal 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) + } + } +} + +extension TextViewController: ThemeAttributesProviding { + public func attributesFor(_ capture: CaptureName?) -> [NSAttributedString.Key: Any] { + [ + .font: font, + .foregroundColor: theme.colorFor(capture), + .kern: textView.kern + ] + } +} diff --git a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift b/Sources/CodeEditTextView/Controller/TextViewController+TextFormation.swift similarity index 96% rename from Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift rename to Sources/CodeEditTextView/Controller/TextViewController+TextFormation.swift index c17c79918..2ce8dd31d 100644 --- a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController+TextFormation.swift @@ -1,16 +1,16 @@ // -// STTextViewController+TextFormation.swift -// +// TextViewController+TextFormation.swift +// // // Created by Khan Winter on 1/26/23. // import AppKit -import STTextView +import CodeEditInputView import TextFormation import TextStory -extension STTextViewController { +extension TextViewController { internal enum BracketPairs { static let allValues: [(String, String)] = [ @@ -88,7 +88,7 @@ extension STTextViewController { /// - mutation: The text mutation. /// - textView: The textView to use. /// - Returns: Return whether or not the mutation should be applied. - internal func shouldApplyMutation(_ mutation: TextMutation, to textView: STTextView) -> Bool { + internal func shouldApplyMutation(_ mutation: TextMutation, to textView: TextView) -> Bool { // don't perform any kind of filtering during undo operations if textView.undoManager?.isUndoing ?? false || textView.undoManager?.isRedoing ?? false { return true diff --git a/Sources/CodeEditTextView/Controller/TextViewController.swift b/Sources/CodeEditTextView/Controller/TextViewController.swift new file mode 100644 index 000000000..e319daded --- /dev/null +++ b/Sources/CodeEditTextView/Controller/TextViewController.swift @@ -0,0 +1,366 @@ +// +// TextViewController.swift +// +// +// Created by Khan Winter on 6/25/23. +// + +import AppKit +import CodeEditInputView +import CodeEditLanguages +import SwiftUI +import Common +import Combine +import TextFormation + +public class TextViewController: NSViewController { + var scrollView: NSScrollView! + var textView: TextView! + var gutterView: GutterView! + /// Internal reference to any injected layers in the text view. + internal var highlightLayers: [CALayer] = [] + private var systemAppearance: NSAppearance.Name? + + /// Binding for the `textView`s string + public var string: Binding + + /// The associated `CodeLanguage` + public var language: CodeLanguage { + didSet { + highlighter?.setLanguage(language: language) + } + } + + /// The font to use in the `textView` + public var font: NSFont { + didSet { + textView.font = font + } + } + + /// The associated `Theme` used for highlighting. + public var theme: EditorTheme { + didSet { + highlighter?.invalidate() + } + } + + /// The visual width of tab characters in the text view measured in number of spaces. + public var tabWidth: Int { + didSet { + paragraphStyle = generateParagraphStyle() + } + } + + /// The behavior to use when the tab key is pressed. + public var indentOption: IndentOption { + didSet { + setUpTextFormation() + } + } + + /// A multiplier for setting the line height. Defaults to `1.0` + public var lineHeightMultiple: CGFloat { + didSet { + textView.layoutManager.lineHeightMultiplier = lineHeightMultiple + } + } + + /// Whether lines wrap to the width of the editor + public var wrapLines: Bool { + didSet { + textView.layoutManager.wrapLines = wrapLines + } + } + + /// The current cursor position e.g. (1, 1) + public var cursorPosition: Binding<(Int, Int)> + + /// The height to overscroll the textview by. + public var editorOverscroll: CGFloat { + didSet { + textView.editorOverscroll = editorOverscroll + } + } + + /// Whether the code editor should use the theme background color or be transparent + public var useThemeBackground: Bool + + /// The provided highlight provider. + public var highlightProvider: HighlightProviding? + + /// Optional insets to offset the text view in the scroll view by. + public var contentInsets: NSEdgeInsets? + + /// Whether or not text view is editable by user + public var isEditable: Bool { + didSet { + textView.isEditable = isEditable + } + } + + /// A multiplier that determines the amount of space between characters. `1.0` indicates no space, + /// `2.0` indicates one character of space between other characters. + public var letterSpacing: Double = 1.0 { + didSet { + textView.letterSpacing = letterSpacing + } + } + + /// The type of highlight to use when highlighting bracket pairs. Leave as `nil` to disable highlighting. + public var bracketPairHighlight: BracketPairHighlight? { + didSet { + highlightSelectionPairs() + } + } + + internal var storageDelegate: MultiStorageDelegate! + internal var highlighter: Highlighter? + + private var fontCharWidth: CGFloat { + (" " as NSString).size(withAttributes: [.font: font]).width + } + + /// Filters used when applying edits.. + internal var textFilters: [TextFormation.Filter] = [] + + /// The pixel value to overscroll the bottom of the editor. + /// Calculated as the line height \* ``TextViewController/editorOverscroll``. + /// Does not include ``TextViewController/contentInsets``. + private var bottomContentInset: CGFloat { + (textView?.estimatedLineHeight() ?? 0) * CGFloat(editorOverscroll) + } + + private var cancellables = Set() + + // MARK: Init + + init( + 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, + bracketPairHighlight: BracketPairHighlight? + ) { + self.string = string + self.language = language + self.font = font + self.theme = theme + self.tabWidth = tabWidth + self.indentOption = indentOption + self.lineHeightMultiple = 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) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Paragraph Style + + /// A default `NSParagraphStyle` with a set `lineHeight` + internal lazy var paragraphStyle: NSMutableParagraphStyle = generateParagraphStyle() + + private func generateParagraphStyle() -> NSMutableParagraphStyle { + // swiftlint:disable:next force_cast + let paragraph = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle + paragraph.tabStops.removeAll() + paragraph.defaultTabInterval = CGFloat(tabWidth) * fontCharWidth + return paragraph + } + + // MARK: Load View + + // swiftlint:disable:next function_body_length + override public func loadView() { + scrollView = NSScrollView() + textView = TextView( + string: string.wrappedValue, + font: font, + textColor: theme.text, + lineHeight: lineHeightMultiple, + wrapLines: wrapLines, + editorOverscroll: bottomContentInset, + isEditable: isEditable, + letterSpacing: letterSpacing, + delegate: self, + storageDelegate: storageDelegate + ) + textView.postsFrameChangedNotifications = true + textView.translatesAutoresizingMaskIntoConstraints = false + textView.selectionManager.insertionPointColor = theme.insertionPoint + + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.contentView.postsFrameChangedNotifications = true + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = true + scrollView.documentView = textView + scrollView.contentView.postsBoundsChangedNotifications = true + if let contentInsets { + scrollView.automaticallyAdjustsContentInsets = false + scrollView.contentInsets = contentInsets + } + + gutterView = GutterView( + font: font.rulerFont, + textColor: .secondaryLabelColor, + textView: textView, + delegate: self + ) + gutterView.frame.origin.y = -scrollView.contentInsets.top + gutterView.updateWidthIfNeeded() + scrollView.addFloatingSubview( + gutterView, + for: .horizontal + ) + + self.view = scrollView + + setUpHighlighter() + + 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) + ]) + + // Layout on scroll change + NotificationCenter.default.addObserver( + forName: NSView.boundsDidChangeNotification, + object: scrollView.contentView, + queue: .main + ) { [weak self] _ in + self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero) + self?.gutterView.needsDisplay = true + } + + // Layout on frame change + NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: scrollView.contentView, + queue: .main + ) { [weak self] _ in + self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero) + self?.gutterView.needsDisplay = true + if self?.bracketPairHighlight == .flash { + self?.removeHighlightLayers() + } + } + + NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: textView, + queue: .main + ) { [weak self] _ in + self?.gutterView.frame.size.height = (self?.textView.frame.height ?? 0) + 10 + self?.gutterView.needsDisplay = true + } + + NotificationCenter.default.addObserver( + forName: TextSelectionManager.selectionChangedNotification, + object: textView.selectionManager, + queue: .main + ) { [weak self] _ in + self?.updateCursorPosition() + self?.highlightSelectionPairs() + } + + textView.updateFrameIfNeeded() + + NSApp.publisher(for: \.effectiveAppearance) + .receive(on: RunLoop.main) + .sink { [weak self] newValue in + guard let self = self else { return } + + if self.systemAppearance != newValue.name { + self.systemAppearance = newValue.name + } + } + .store(in: &cancellables) + } + + // MARK: - Reload UI + + func reloadUI() { + textView.isEditable = isEditable + textView.editorOverscroll = bottomContentInset + + textView.selectionManager.selectionBackgroundColor = theme.selection + textView.selectionManager.selectedLineBackgroundColor = useThemeBackground + ? theme.lineHighlight + : systemAppearance == .darkAqua + ? NSColor.quaternaryLabelColor : NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) + textView.selectionManager.highlightSelectedLine = isEditable + textView.selectionManager.insertionPointColor = theme.insertionPoint + paragraphStyle = generateParagraphStyle() + textView.typingAttributes = attributesFor(nil) + + gutterView.selectedLineColor = useThemeBackground ? theme.lineHighlight : systemAppearance == .darkAqua + ? NSColor.quaternaryLabelColor + : NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) + gutterView.highlightSelectedLines = isEditable + gutterView.font = font.rulerFont + gutterView.backgroundColor = theme.background + if self.isEditable == false { + gutterView.selectedLineTextColor = nil + gutterView.selectedLineColor = .clear + } + + if let scrollView = view as? NSScrollView { + scrollView.drawsBackground = useThemeBackground + scrollView.backgroundColor = useThemeBackground ? theme.background : .clear + if let contentInsets = contentInsets { + scrollView.contentInsets = contentInsets + } + scrollView.contentInsets.bottom = bottomContentInset + (contentInsets?.bottom ?? 0) + } + + highlighter?.invalidate() + // highlightSelectionPairs() + } + + deinit { + highlighter = nil + highlightProvider = nil + storageDelegate = nil + NotificationCenter.default.removeObserver(self) + cancellables.forEach { $0.cancel() } + } +} + +extension TextViewController: TextViewDelegate { + public func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with: String) { + gutterView.needsDisplay = true + } +} + +extension TextViewController: GutterViewDelegate { + public func gutterViewWidthDidUpdate(newWidth: CGFloat) { + gutterView?.frame.size.width = newWidth + textView?.edgeInsets.left = newWidth + } +} diff --git a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+isEmpty.swift b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+isEmpty.swift new file mode 100644 index 000000000..d06be1df2 --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+isEmpty.swift @@ -0,0 +1,14 @@ +// +// NSRange+isEmpty.swift +// +// +// Created by Khan Winter on 10/14/23. +// + +import Foundation + +extension NSRange { + var isEmpty: Bool { + length == 0 + } +} diff --git a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+AutoComplete.swift b/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+AutoComplete.swift deleted file mode 100644 index 12046d590..000000000 --- a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+AutoComplete.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// STTextView+AutoComplete.swift -// CodeEditTextView -// -// Created by Lukas Pistrol on 25.05.22. -// - -import AppKit -import STTextView - -extension STTextView { - - /// Corresponding closing brackets for given opening bracket. - /// - /// The following pairs are currently implemented: - /// * `(` : `)` - /// * `{` : `}` - /// * `[` : `]` - private var bracketPairs: [String: String] { - [ - "(": ")", - "{": "}", - "[": "]" - // not working yet - // "\"": "\"", - // "\'": "\'" - ] - } - - /// Add closing bracket and move curser back one symbol if applicable. - /// - Parameter symbol: The symbol to check for - func autocompleteBracketPairs(_ symbol: String) { - guard let end = bracketPairs[symbol], - nextSymbol() != end else { return } - insertText(end, replacementRange: selectedRange()) - moveBackward(self) - } - - /// Returns the symbol right of the cursor. - private func nextSymbol() -> String { - let start = selectedRange().location - let nextRange = NSRange(location: start, length: 1) - guard let nextSymbol = string[nextRange] else { - return "" - } - return String(nextSymbol) - } -} diff --git a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+ContentStorage.swift b/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+ContentStorage.swift deleted file mode 100644 index e7f1f29c2..000000000 --- a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+ContentStorage.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// STTextView+ContentStorage.swift -// -// -// Created by Khan Winter on 4/24/23. -// - -import Foundation -import AppKit -import STTextView - -extension STTextView { - /// Convenience that unwraps `textContentManager` as an `NSTextContentStorage` subclass. - var textContentStorage: NSTextContentStorage? { - return textContentManager as? NSTextContentStorage - } -} diff --git a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+HighlighterTextView.swift b/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+HighlighterTextView.swift deleted file mode 100644 index a9e7b0cf6..000000000 --- a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+HighlighterTextView.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// STTextView+HighlighterTextView.swift -// CodeEditTextView -// -// Created by Khan Winter on 6/2/23. -// - -import Foundation -import STTextView - -/// A default implementation for `STTextView` to be passed to `HighlightProviding` objects. -extension STTextView: HighlighterTextView { - public var documentRange: NSRange { - return NSRange( - location: 0, - length: textContentStorage?.textStorage?.length ?? 0 - ) - } - - public func stringForRange(_ nsRange: NSRange) -> String? { - return textContentStorage?.textStorage?.mutableString.substring(with: nsRange) - } -} diff --git a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+TextInterface.swift b/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+TextInterface.swift deleted file mode 100644 index 9a1e7368a..000000000 --- a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+TextInterface.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// STTextView+TextInterface.swift -// -// -// Created by Khan Winter on 1/26/23. -// - -import AppKit -import STTextView -import TextStory -import TextFormation - -extension STTextView: TextInterface { - public var selectedRange: NSRange { - get { - return self.selectedRange() - } - set { - guard let textContentStorage = textContentStorage else { - return - } - if let textRange = NSTextRange(newValue, provider: textContentStorage) { - self.setSelectedTextRange(textRange) - } - } - } - - public var length: Int { - textContentStorage?.length ?? 0 - } - - public func substring(from range: NSRange) -> String? { - return textContentStorage?.substring(from: range) - } - - /// Applies the mutation to the text view. - /// - Parameter mutation: The mutation to apply. - public func applyMutation(_ mutation: TextMutation) { - registerUndo(mutation) - applyMutationNoUndo(mutation) - } - - fileprivate func registerUndo(_ mutation: TextMutation) { - - } - - public func applyMutationNoUndo(_ mutation: TextMutation) { - textContentStorage?.performEditingTransaction { - textContentStorage?.applyMutation(mutation) - } - - let delegate = self.delegate - self.delegate = nil - didChangeText() - self.delegate = delegate - } -} diff --git a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+VisibleRange.swift b/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+VisibleRange.swift deleted file mode 100644 index a79376979..000000000 --- a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+VisibleRange.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// STTextView+VisibleRange.swift -// -// -// Created by Khan Winter on 9/12/22. -// - -import Foundation -import STTextView -import AppKit - -extension STTextView { - /// A helper for calculating the visible range on the text view with some small padding. - var visibleTextRange: NSRange? { - guard let textContentStorage = textContentStorage, - var range = textLayoutManager - .textViewportLayoutController - .viewportRange? - .nsRange(using: textContentStorage) else { - return nil - } - range.location = max(range.location - 2500, 0) - range.length = min(range.length + 2500, textContentStorage.length) - return range - } -} diff --git a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+Menu.swift b/Sources/CodeEditTextView/Extensions/TextView+/TextView+Menu.swift similarity index 98% rename from Sources/CodeEditTextView/Extensions/STTextView+/STTextView+Menu.swift rename to Sources/CodeEditTextView/Extensions/TextView+/TextView+Menu.swift index 105bc949f..576380c51 100644 --- a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+Menu.swift +++ b/Sources/CodeEditTextView/Extensions/TextView+/TextView+Menu.swift @@ -6,9 +6,9 @@ // import AppKit -import STTextView +import CodeEditInputView -extension STTextView { +extension TextView { /// Setup context menus func setupMenus() { diff --git a/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift b/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift new file mode 100644 index 000000000..612248472 --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift @@ -0,0 +1,40 @@ +// +// TextView+TextFormation.swift +// +// +// Created by Khan Winter on 10/14/23. +// + +import Foundation +import CodeEditInputView +import TextStory +import TextFormation + +extension TextView: TextInterface { + public var selectedRange: NSRange { + get { + return selectionManager + .textSelections + .sorted(by: { $0.range.lowerBound < $1.range.lowerBound }) + .first? + .range ?? .zero + } + set { + selectionManager.setSelectedRange(newValue) + } + } + + public var length: Int { + textStorage.length + } + + public func substring(from range: NSRange) -> String? { + return textStorage.substring(from: range) + } + + /// Applies the mutation to the text view. + /// - Parameter mutation: The mutation to apply. + public func applyMutation(_ mutation: TextMutation) { + replaceCharacters(in: mutation.range, with: mutation.string) + } +} diff --git a/Sources/CodeEditTextView/Filters/DeleteWhitespaceFilter.swift b/Sources/CodeEditTextView/Filters/DeleteWhitespaceFilter.swift index 18478c97e..a5f92579b 100644 --- a/Sources/CodeEditTextView/Filters/DeleteWhitespaceFilter.swift +++ b/Sources/CodeEditTextView/Filters/DeleteWhitespaceFilter.swift @@ -6,9 +6,9 @@ // import Foundation +import CodeEditInputView import TextFormation import TextStory -import STTextView /// Filter for quickly deleting indent whitespace /// @@ -59,8 +59,10 @@ struct DeleteWhitespaceFilter: Filter { ) ) - if let textView = interface as? STTextView, textView.textLayoutManager.textSelections.count == 1 { - textView.setSelectedRange(NSRange(location: leadingWhitespace.max - numberOfExtraSpaces, length: 0)) + if let textView = interface as? TextView, textView.selectionManager.textSelections.count == 1 { + textView.selectionManager.setSelectedRange( + NSRange(location: leadingWhitespace.max - numberOfExtraSpaces, length: 0) + ) } return .discard diff --git a/Sources/CodeEditTextView/Gutter/GutterView.swift b/Sources/CodeEditTextView/Gutter/GutterView.swift index 1cd5c8b03..1dd65120e 100644 --- a/Sources/CodeEditTextView/Gutter/GutterView.swift +++ b/Sources/CodeEditTextView/Gutter/GutterView.swift @@ -39,7 +39,7 @@ public class GutterView: NSView { var highlightSelectedLines: Bool = true @Invalidating(.display) - var selectedLineTextColor: NSColor = .textColor + var selectedLineTextColor: NSColor? = .textColor @Invalidating(.display) var selectedLineColor: NSColor = NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) @@ -166,7 +166,7 @@ public class GutterView: NSView { context.textMatrix = CGAffineTransform(scaleX: 1, y: -1) for linePosition in textView.layoutManager.visibleLines() { if selectionRangeMap.intersects(integersIn: linePosition.range) { - attributes[.foregroundColor] = selectedLineTextColor + attributes[.foregroundColor] = selectedLineTextColor ?? textColor } else { attributes[.foregroundColor] = textColor } diff --git a/Sources/CodeEditTextView/TextViewController.swift b/Sources/CodeEditTextView/TextViewController.swift deleted file mode 100644 index d1b4aca8b..000000000 --- a/Sources/CodeEditTextView/TextViewController.swift +++ /dev/null @@ -1,231 +0,0 @@ -// -// TextViewController.swift -// -// -// Created by Khan Winter on 6/25/23. -// - -import AppKit -import CodeEditInputView -import CodeEditLanguages -import SwiftUI -import SwiftTreeSitter -import Common - -public class TextViewController: NSViewController { - var scrollView: NSScrollView! - var textView: TextView! - var gutterView: GutterView! - - public var string: Binding - public var language: CodeLanguage - public var font: NSFont { - didSet { - textView.font = font - } - } - 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: 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, - 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) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // swiftlint:disable:next function_body_length - override public func loadView() { - scrollView = NSScrollView() - textView = TextView( - string: string.wrappedValue, - font: font, - lineHeight: lineHeight, - wrapLines: wrapLines, - editorOverscroll: editorOverscroll, - isEditable: isEditable, - letterSpacing: letterSpacing, - delegate: self, - storageDelegate: storageDelegate - ) - textView.postsFrameChangedNotifications = true - textView.translatesAutoresizingMaskIntoConstraints = false - - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.contentView.postsFrameChangedNotifications = true - scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = true - scrollView.documentView = textView - scrollView.contentView.postsBoundsChangedNotifications = true - if let contentInsets { - scrollView.automaticallyAdjustsContentInsets = false - scrollView.contentInsets = contentInsets - } - - gutterView = GutterView( - font: font.rulerFont, - textColor: .secondaryLabelColor, - textView: textView, - delegate: self - ) - gutterView.frame.origin.y = -scrollView.contentInsets.top - gutterView.updateWidthIfNeeded() - scrollView.addFloatingSubview( - gutterView, - for: .horizontal - ) - - self.view = scrollView - - setUpHighlighter() - - 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) - ]) - - // Layout on scroll change - NotificationCenter.default.addObserver( - forName: NSView.boundsDidChangeNotification, - object: scrollView.contentView, - queue: .main - ) { [weak self] _ in - self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero) - self?.gutterView.needsDisplay = true - } - - // Layout on frame change - NotificationCenter.default.addObserver( - forName: NSView.frameDidChangeNotification, - object: scrollView.contentView, - queue: .main - ) { [weak self] _ in - self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero) - self?.gutterView.needsDisplay = true - } - - NotificationCenter.default.addObserver( - forName: NSView.frameDidChangeNotification, - object: textView, - queue: .main - ) { [weak self] _ in - self?.gutterView.frame.size.height = (self?.textView.frame.height ?? 0) + 10 - self?.gutterView.needsDisplay = true - } - - textView.updateFrameIfNeeded() - } - - deinit { - highlighter = nil - highlightProvider = nil - storageDelegate = nil - NotificationCenter.default.removeObserver(self) - } -} - -extension TextViewController: ThemeAttributesProviding { - public func attributesFor(_ capture: CaptureName?) -> [NSAttributedString.Key: Any] { - [ - .font: font, - .foregroundColor: theme.colorFor(capture) -// .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) - } - } -} - -extension TextViewController: TextViewDelegate { - public func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with: String) { - gutterView.needsDisplay = true - } -} - -extension TextViewController: GutterViewDelegate { - public func gutterViewWidthDidUpdate(newWidth: CGFloat) { - gutterView?.frame.size.width = newWidth - textView?.edgeInsets.left = newWidth - } -} diff --git a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift b/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift index 161299466..5f2f28b78 100644 --- a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift +++ b/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift @@ -7,283 +7,283 @@ import TextStory // swiftlint:disable all final class STTextViewControllerTests: XCTestCase { - var controller: STTextViewController! - var theme: EditorTheme! - - override func setUpWithError() throws { - theme = EditorTheme( - text: .textColor, - insertionPoint: .textColor, - invisibles: .gray, - background: .textBackgroundColor, - lineHighlight: .highlightColor, - selection: .selectedTextColor, - keywords: .systemPink, - commands: .systemBlue, - types: .systemMint, - attributes: .systemTeal, - variables: .systemCyan, - values: .systemOrange, - numbers: .systemYellow, - strings: .systemRed, - characters: .systemRed, - comments: .systemGreen - ) - controller = STTextViewController( - text: .constant(""), - language: .default, - font: .monospacedSystemFont(ofSize: 11, weight: .medium), - theme: theme, - tabWidth: 4, - indentOption: .spaces(count: 4), - lineHeight: 1.0, - wrapLines: true, - cursorPosition: .constant((1, 1)), - editorOverscroll: 0.5, - useThemeBackground: true, - isEditable: true, - letterSpacing: 1.0 - ) - - controller.loadView() - } - - func test_captureNames() throws { - // test for "keyword" - let captureName1 = "keyword" - let color1 = controller.attributesFor(CaptureName(rawValue: captureName1))[.foregroundColor] as? NSColor - XCTAssertEqual(color1, NSColor.systemPink) - - // test for "comment" - let captureName2 = "comment" - let color2 = controller.attributesFor(CaptureName(rawValue: captureName2))[.foregroundColor] as? NSColor - XCTAssertEqual(color2, NSColor.systemGreen) - - /* ... additional tests here ... */ - - // test for empty case - let captureName3 = "" - let color3 = controller.attributesFor(CaptureName(rawValue: captureName3))[.foregroundColor] as? NSColor - XCTAssertEqual(color3, NSColor.textColor) - - // test for random case - let captureName4 = "abc123" - let color4 = controller.attributesFor(CaptureName(rawValue: captureName4))[.foregroundColor] as? NSColor - XCTAssertEqual(color4, NSColor.textColor) - } - - func test_editorOverScroll() throws { - let scrollView = try XCTUnwrap(controller.view as? NSScrollView) - scrollView.frame = .init(x: .zero, - y: .zero, - width: 100, - height: 100) - - controller.editorOverscroll = 0 - controller.contentInsets = nil - controller.reloadUI() - - // editorOverscroll: 0 - XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 0) - - controller.editorOverscroll = 0.5 - controller.reloadUI() - - // editorOverscroll: 0.5 - XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 50.0) - - controller.editorOverscroll = 1.0 - controller.reloadUI() - - // editorOverscroll: 1.0 - XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 87.0) - } - - func test_editorInsets() throws { - let scrollView = try XCTUnwrap(controller.view as? NSScrollView) - scrollView.frame = .init(x: .zero, - y: .zero, - width: 100, - height: 100) - - func assertInsetsEqual(_ lhs: NSEdgeInsets, _ rhs: NSEdgeInsets) throws { - XCTAssertEqual(lhs.top, rhs.top) - XCTAssertEqual(lhs.right, rhs.right) - XCTAssertEqual(lhs.bottom, rhs.bottom) - XCTAssertEqual(lhs.left, rhs.left) - } - - controller.editorOverscroll = 0 - controller.contentInsets = nil - controller.reloadUI() - - // contentInsets: 0 - try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)) - - // contentInsets: 16 - controller.contentInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) - controller.reloadUI() - - try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)) - - // contentInsets: different - controller.contentInsets = NSEdgeInsets(top: 32.5, left: 12.3, bottom: 20, right: 1) - controller.reloadUI() - - try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 32.5, left: 12.3, bottom: 20, right: 1)) - - // contentInsets: 16 - // editorOverscroll: 0.5 - controller.contentInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) - controller.editorOverscroll = 0.5 - controller.reloadUI() - - try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 16, left: 16, bottom: 16 + 50, right: 16)) - } - - func test_editorOverScroll_ZeroCondition() throws { - let scrollView = try XCTUnwrap(controller.view as? NSScrollView) - scrollView.frame = .zero - - // editorOverscroll: 0 - XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 0) - } - - func test_indentOptionString() { - XCTAssertEqual(" ", IndentOption.spaces(count: 1).stringValue) - XCTAssertEqual(" ", IndentOption.spaces(count: 2).stringValue) - XCTAssertEqual(" ", IndentOption.spaces(count: 3).stringValue) - XCTAssertEqual(" ", IndentOption.spaces(count: 4).stringValue) - XCTAssertEqual(" ", IndentOption.spaces(count: 5).stringValue) - - XCTAssertEqual("\t", IndentOption.tab.stringValue) - } - - func test_indentBehavior() { - // Insert 1 space - controller.indentOption = .spaces(count: 1) - controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") - controller.insertTab(nil) - XCTAssertEqual(controller.textView.string, " ") - - // Insert 2 spaces - controller.indentOption = .spaces(count: 2) - controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") - controller.textView.insertText("\t", replacementRange: .zero) - XCTAssertEqual(controller.textView.string, " ") - - // Insert 3 spaces - controller.indentOption = .spaces(count: 3) - controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") - controller.textView.insertText("\t", replacementRange: .zero) - XCTAssertEqual(controller.textView.string, " ") - - // Insert 4 spaces - controller.indentOption = .spaces(count: 4) - controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") - controller.textView.insertText("\t", replacementRange: .zero) - XCTAssertEqual(controller.textView.string, " ") - - // Insert tab - controller.indentOption = .tab - controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") - controller.textView.insertText("\t", replacementRange: .zero) - XCTAssertEqual(controller.textView.string, "\t") - - // Insert lots of spaces - controller.indentOption = .spaces(count: 1000) - controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") - controller.textView.insertText("\t", replacementRange: .zero) - XCTAssertEqual(controller.textView.string, String(repeating: " ", count: 1000)) - } - - func test_letterSpacing() { - let font: NSFont = .monospacedSystemFont(ofSize: 11, weight: .medium) - - controller.letterSpacing = 1.0 - - XCTAssertEqual( - controller.attributesFor(nil)[.kern]! as! CGFloat, - (" " as NSString).size(withAttributes: [.font: font]).width * 0.0 - ) - - controller.letterSpacing = 2.0 - XCTAssertEqual( - controller.attributesFor(nil)[.kern]! as! CGFloat, - (" " as NSString).size(withAttributes: [.font: font]).width * 1.0 - ) - - controller.letterSpacing = 1.0 - } - - func test_bracketHighlights() { - controller.viewDidLoad() - controller.bracketPairHighlight = nil - controller.textView.string = "{ Loren Ipsum {} }" - controller.setCursorPosition((1, 2)) // After first opening { - XCTAssert(controller.highlightLayers.isEmpty, "Controller added highlight layer when setting is set to `nil`") - controller.setCursorPosition((1, 3)) - - controller.bracketPairHighlight = .bordered(color: .black) - controller.setCursorPosition((1, 2)) // After first opening { - XCTAssert(controller.highlightLayers.count == 2, "Controller created an incorrect number of layers for bordered. Expected 2, found \(controller.highlightLayers.count)") - controller.setCursorPosition((1, 3)) - XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") - - controller.bracketPairHighlight = .underline(color: .black) - controller.setCursorPosition((1, 2)) // After first opening { - XCTAssert(controller.highlightLayers.count == 2, "Controller created an incorrect number of layers for underline. Expected 2, found \(controller.highlightLayers.count)") - controller.setCursorPosition((1, 3)) - XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") - - controller.bracketPairHighlight = .flash - controller.setCursorPosition((1, 2)) // After first opening { - XCTAssert(controller.highlightLayers.count == 1, "Controller created more than one layer for flash animation. Expected 1, found \(controller.highlightLayers.count)") - controller.setCursorPosition((1, 3)) - XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") - - controller.setCursorPosition((1, 2)) // After first opening { - XCTAssert(controller.highlightLayers.count == 1, "Controller created more than one layer for flash animation. Expected 1, found \(controller.highlightLayers.count)") - let exp = expectation(description: "Test after 0.8 seconds") - let result = XCTWaiter.wait(for: [exp], timeout: 0.8) - if result == XCTWaiter.Result.timedOut { - XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove layer after flash animation. Expected 0, found \(controller.highlightLayers.count)") - } else { - XCTFail("Delay interrupted") - } - } - - func test_findClosingPair() { - controller.textView.string = "{ Loren Ipsum {} }" - var idx: Int? - - // Test walking forwards - idx = controller.findClosingPair("{", "}", from: 1, limit: 18, reverse: false) - XCTAssert(idx == 17, "Walking forwards failed. Expected `17`, found: `\(String(describing: idx))`") - - // Test walking backwards - idx = controller.findClosingPair("}", "{", from: 17, limit: 0, reverse: true) - XCTAssert(idx == 0, "Walking backwards failed. Expected `0`, found: `\(String(describing: idx))`") - - // Test extra pair - controller.textView.string = "{ Loren Ipsum {}} }" - idx = controller.findClosingPair("{", "}", from: 1, limit: 19, reverse: false) - XCTAssert(idx == 16, "Walking forwards with extra bracket pair failed. Expected `16`, found: `\(String(describing: idx))`") - - // Text extra pair backwards - controller.textView.string = "{ Loren Ipsum {{} }" - idx = controller.findClosingPair("}", "{", from: 18, limit: 0, reverse: true) - XCTAssert(idx == 14, "Walking backwards with extra bracket pair failed. Expected `14`, found: `\(String(describing: idx))`") - - // Test missing pair - controller.textView.string = "{ Loren Ipsum { }" - idx = controller.findClosingPair("{", "}", from: 1, limit: 17, reverse: false) - XCTAssert(idx == nil, "Walking forwards with missing pair failed. Expected `nil`, found: `\(String(describing: idx))`") - - // Test missing pair backwards - controller.textView.string = " Loren Ipsum {} }" - idx = controller.findClosingPair("}", "{", from: 17, limit: 0, reverse: true) - XCTAssert(idx == nil, "Walking backwards with missing pair failed. Expected `nil`, found: `\(String(describing: idx))`") - } +// var controller: STTextViewController! +// var theme: EditorTheme! +// +// override func setUpWithError() throws { +// theme = EditorTheme( +// text: .textColor, +// insertionPoint: .textColor, +// invisibles: .gray, +// background: .textBackgroundColor, +// lineHighlight: .highlightColor, +// selection: .selectedTextColor, +// keywords: .systemPink, +// commands: .systemBlue, +// types: .systemMint, +// attributes: .systemTeal, +// variables: .systemCyan, +// values: .systemOrange, +// numbers: .systemYellow, +// strings: .systemRed, +// characters: .systemRed, +// comments: .systemGreen +// ) +// controller = STTextViewController( +// text: .constant(""), +// language: .default, +// font: .monospacedSystemFont(ofSize: 11, weight: .medium), +// theme: theme, +// tabWidth: 4, +// indentOption: .spaces(count: 4), +// lineHeight: 1.0, +// wrapLines: true, +// cursorPosition: .constant((1, 1)), +// editorOverscroll: 0.5, +// useThemeBackground: true, +// isEditable: true, +// letterSpacing: 1.0 +// ) +// +// controller.loadView() +// } +// +// func test_captureNames() throws { +// // test for "keyword" +// let captureName1 = "keyword" +// let color1 = controller.attributesFor(CaptureName(rawValue: captureName1))[.foregroundColor] as? NSColor +// XCTAssertEqual(color1, NSColor.systemPink) +// +// // test for "comment" +// let captureName2 = "comment" +// let color2 = controller.attributesFor(CaptureName(rawValue: captureName2))[.foregroundColor] as? NSColor +// XCTAssertEqual(color2, NSColor.systemGreen) +// +// /* ... additional tests here ... */ +// +// // test for empty case +// let captureName3 = "" +// let color3 = controller.attributesFor(CaptureName(rawValue: captureName3))[.foregroundColor] as? NSColor +// XCTAssertEqual(color3, NSColor.textColor) +// +// // test for random case +// let captureName4 = "abc123" +// let color4 = controller.attributesFor(CaptureName(rawValue: captureName4))[.foregroundColor] as? NSColor +// XCTAssertEqual(color4, NSColor.textColor) +// } +// +// func test_editorOverScroll() throws { +// let scrollView = try XCTUnwrap(controller.view as? NSScrollView) +// scrollView.frame = .init(x: .zero, +// y: .zero, +// width: 100, +// height: 100) +// +// controller.editorOverscroll = 0 +// controller.contentInsets = nil +// controller.reloadUI() +// +// // editorOverscroll: 0 +// XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 0) +// +// controller.editorOverscroll = 0.5 +// controller.reloadUI() +// +// // editorOverscroll: 0.5 +// XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 50.0) +// +// controller.editorOverscroll = 1.0 +// controller.reloadUI() +// +// // editorOverscroll: 1.0 +// XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 87.0) +// } +// +// func test_editorInsets() throws { +// let scrollView = try XCTUnwrap(controller.view as? NSScrollView) +// scrollView.frame = .init(x: .zero, +// y: .zero, +// width: 100, +// height: 100) +// +// func assertInsetsEqual(_ lhs: NSEdgeInsets, _ rhs: NSEdgeInsets) throws { +// XCTAssertEqual(lhs.top, rhs.top) +// XCTAssertEqual(lhs.right, rhs.right) +// XCTAssertEqual(lhs.bottom, rhs.bottom) +// XCTAssertEqual(lhs.left, rhs.left) +// } +// +// controller.editorOverscroll = 0 +// controller.contentInsets = nil +// controller.reloadUI() +// +// // contentInsets: 0 +// try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)) +// +// // contentInsets: 16 +// controller.contentInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) +// controller.reloadUI() +// +// try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)) +// +// // contentInsets: different +// controller.contentInsets = NSEdgeInsets(top: 32.5, left: 12.3, bottom: 20, right: 1) +// controller.reloadUI() +// +// try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 32.5, left: 12.3, bottom: 20, right: 1)) +// +// // contentInsets: 16 +// // editorOverscroll: 0.5 +// controller.contentInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) +// controller.editorOverscroll = 0.5 +// controller.reloadUI() +// +// try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 16, left: 16, bottom: 16 + 50, right: 16)) +// } +// +// func test_editorOverScroll_ZeroCondition() throws { +// let scrollView = try XCTUnwrap(controller.view as? NSScrollView) +// scrollView.frame = .zero +// +// // editorOverscroll: 0 +// XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 0) +// } +// +// func test_indentOptionString() { +// XCTAssertEqual(" ", IndentOption.spaces(count: 1).stringValue) +// XCTAssertEqual(" ", IndentOption.spaces(count: 2).stringValue) +// XCTAssertEqual(" ", IndentOption.spaces(count: 3).stringValue) +// XCTAssertEqual(" ", IndentOption.spaces(count: 4).stringValue) +// XCTAssertEqual(" ", IndentOption.spaces(count: 5).stringValue) +// +// XCTAssertEqual("\t", IndentOption.tab.stringValue) +// } +// +// func test_indentBehavior() { +// // Insert 1 space +// controller.indentOption = .spaces(count: 1) +// controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") +// controller.insertTab(nil) +// XCTAssertEqual(controller.textView.string, " ") +// +// // Insert 2 spaces +// controller.indentOption = .spaces(count: 2) +// controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") +// controller.textView.insertText("\t", replacementRange: .zero) +// XCTAssertEqual(controller.textView.string, " ") +// +// // Insert 3 spaces +// controller.indentOption = .spaces(count: 3) +// controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") +// controller.textView.insertText("\t", replacementRange: .zero) +// XCTAssertEqual(controller.textView.string, " ") +// +// // Insert 4 spaces +// controller.indentOption = .spaces(count: 4) +// controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") +// controller.textView.insertText("\t", replacementRange: .zero) +// XCTAssertEqual(controller.textView.string, " ") +// +// // Insert tab +// controller.indentOption = .tab +// controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") +// controller.textView.insertText("\t", replacementRange: .zero) +// XCTAssertEqual(controller.textView.string, "\t") +// +// // Insert lots of spaces +// controller.indentOption = .spaces(count: 1000) +// controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") +// controller.textView.insertText("\t", replacementRange: .zero) +// XCTAssertEqual(controller.textView.string, String(repeating: " ", count: 1000)) +// } +// +// func test_letterSpacing() { +// let font: NSFont = .monospacedSystemFont(ofSize: 11, weight: .medium) +// +// controller.letterSpacing = 1.0 +// +// XCTAssertEqual( +// controller.attributesFor(nil)[.kern]! as! CGFloat, +// (" " as NSString).size(withAttributes: [.font: font]).width * 0.0 +// ) +// +// controller.letterSpacing = 2.0 +// XCTAssertEqual( +// controller.attributesFor(nil)[.kern]! as! CGFloat, +// (" " as NSString).size(withAttributes: [.font: font]).width * 1.0 +// ) +// +// controller.letterSpacing = 1.0 +// } +// +// func test_bracketHighlights() { +// controller.viewDidLoad() +// controller.bracketPairHighlight = nil +// controller.textView.string = "{ Loren Ipsum {} }" +// controller.setCursorPosition((1, 2)) // After first opening { +// XCTAssert(controller.highlightLayers.isEmpty, "Controller added highlight layer when setting is set to `nil`") +// controller.setCursorPosition((1, 3)) +// +// controller.bracketPairHighlight = .bordered(color: .black) +// controller.setCursorPosition((1, 2)) // After first opening { +// XCTAssert(controller.highlightLayers.count == 2, "Controller created an incorrect number of layers for bordered. Expected 2, found \(controller.highlightLayers.count)") +// controller.setCursorPosition((1, 3)) +// XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") +// +// controller.bracketPairHighlight = .underline(color: .black) +// controller.setCursorPosition((1, 2)) // After first opening { +// XCTAssert(controller.highlightLayers.count == 2, "Controller created an incorrect number of layers for underline. Expected 2, found \(controller.highlightLayers.count)") +// controller.setCursorPosition((1, 3)) +// XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") +// +// controller.bracketPairHighlight = .flash +// controller.setCursorPosition((1, 2)) // After first opening { +// XCTAssert(controller.highlightLayers.count == 1, "Controller created more than one layer for flash animation. Expected 1, found \(controller.highlightLayers.count)") +// controller.setCursorPosition((1, 3)) +// XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") +// +// controller.setCursorPosition((1, 2)) // After first opening { +// XCTAssert(controller.highlightLayers.count == 1, "Controller created more than one layer for flash animation. Expected 1, found \(controller.highlightLayers.count)") +// let exp = expectation(description: "Test after 0.8 seconds") +// let result = XCTWaiter.wait(for: [exp], timeout: 0.8) +// if result == XCTWaiter.Result.timedOut { +// XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove layer after flash animation. Expected 0, found \(controller.highlightLayers.count)") +// } else { +// XCTFail("Delay interrupted") +// } +// } +// +// func test_findClosingPair() { +// controller.textView.string = "{ Loren Ipsum {} }" +// var idx: Int? +// +// // Test walking forwards +// idx = controller.findClosingPair("{", "}", from: 1, limit: 18, reverse: false) +// XCTAssert(idx == 17, "Walking forwards failed. Expected `17`, found: `\(String(describing: idx))`") +// +// // Test walking backwards +// idx = controller.findClosingPair("}", "{", from: 17, limit: 0, reverse: true) +// XCTAssert(idx == 0, "Walking backwards failed. Expected `0`, found: `\(String(describing: idx))`") +// +// // Test extra pair +// controller.textView.string = "{ Loren Ipsum {}} }" +// idx = controller.findClosingPair("{", "}", from: 1, limit: 19, reverse: false) +// XCTAssert(idx == 16, "Walking forwards with extra bracket pair failed. Expected `16`, found: `\(String(describing: idx))`") +// +// // Text extra pair backwards +// controller.textView.string = "{ Loren Ipsum {{} }" +// idx = controller.findClosingPair("}", "{", from: 18, limit: 0, reverse: true) +// XCTAssert(idx == 14, "Walking backwards with extra bracket pair failed. Expected `14`, found: `\(String(describing: idx))`") +// +// // Test missing pair +// controller.textView.string = "{ Loren Ipsum { }" +// idx = controller.findClosingPair("{", "}", from: 1, limit: 17, reverse: false) +// XCTAssert(idx == nil, "Walking forwards with missing pair failed. Expected `nil`, found: `\(String(describing: idx))`") +// +// // Test missing pair backwards +// controller.textView.string = " Loren Ipsum {} }" +// idx = controller.findClosingPair("}", "{", from: 17, limit: 0, reverse: true) +// XCTAssert(idx == nil, "Walking backwards with missing pair failed. Expected `nil`, found: `\(String(describing: idx))`") +// } } // swiftlint:enable all From 9e6a7b546befbe5bc8e73998d09e5fdb17b0f475 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 14 Oct 2023 15:50:52 -0500 Subject: [PATCH 51/75] Remove Print Statement --- Sources/CodeEditTextView/CodeEditTextView.swift | 3 ++- .../Controller/TextViewController+HighlightBracket.swift | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index 50edb99be..0ac71abd9 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditTextView/CodeEditTextView.swift @@ -123,7 +123,6 @@ public struct CodeEditTextView: NSViewControllerRepresentable { controller.lineHeightMultiple = lineHeight controller.editorOverscroll = editorOverscroll controller.contentInsets = contentInsets - controller.bracketPairHighlight = bracketPairHighlight if controller.isEditable != isEditable { controller.isEditable = isEditable } @@ -144,6 +143,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable { controller.letterSpacing = letterSpacing } + controller.bracketPairHighlight = bracketPairHighlight + controller.reloadUI() return } diff --git a/Sources/CodeEditTextView/Controller/TextViewController+HighlightBracket.swift b/Sources/CodeEditTextView/Controller/TextViewController+HighlightBracket.swift index c545baa36..207f10b6c 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController+HighlightBracket.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController+HighlightBracket.swift @@ -110,7 +110,6 @@ extension TextViewController { var rectToHighlight = textView.layoutManager.rectForOffset(location) else { return } - print(rectToHighlight) let layer = CAShapeLayer() switch bracketPairHighlight { From 61a1b9b88154c872ddc75044d5700840dc977197 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 14 Oct 2023 16:09:31 -0500 Subject: [PATCH 52/75] Accessibility --- .../TextView/TextView+Accessibility.swift | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 Sources/CodeEditInputView/TextView/TextView+Accessibility.swift diff --git a/Sources/CodeEditInputView/TextView/TextView+Accessibility.swift b/Sources/CodeEditInputView/TextView/TextView+Accessibility.swift new file mode 100644 index 000000000..df94e05fa --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+Accessibility.swift @@ -0,0 +1,120 @@ +// +// TextView+Accessibility.swift +// +// +// Created by Khan Winter on 10/14/23. +// + +import AppKit + +extension TextView { + override open func isAccessibilityElement() -> Bool { + true + } + + override open func isAccessibilityEnabled() -> Bool { + true + } + + override open func isAccessibilityFocused() -> Bool { + isFirstResponder + } + + override open func accessibilityLabel() -> String? { + "Text Editor" + } + + override open func accessibilityRole() -> NSAccessibility.Role? { + .textArea + } + + override open func accessibilityValue() -> Any? { + string + } + + override open func setAccessibilityValue(_ accessibilityValue: Any?) { + guard let string = accessibilityValue as? String else { + return + } + + self.string = string + } + + override open func accessibilityString(for range: NSRange) -> String? { + textStorage.substring( + from: textStorage.mutableString.rangeOfComposedCharacterSequences(for: range) + ) + } + + // MARK: Selections + + override open func accessibilitySelectedText() -> String? { + guard let selection = selectionManager + .textSelections + .sorted(by: { $0.range.lowerBound < $1.range.lowerBound }) + .first else { + return nil + } + let range = (textStorage.string as NSString).rangeOfComposedCharacterSequences(for: selection.range) + return textStorage.substring(from: range) + } + + override open func accessibilitySelectedTextRange() -> NSRange { + guard let selection = selectionManager + .textSelections + .sorted(by: { $0.range.lowerBound < $1.range.lowerBound }) + .first else { + return .zero + } + return textStorage.mutableString.rangeOfComposedCharacterSequences(for: selection.range) + } + + override open func accessibilitySelectedTextRanges() -> [NSValue]? { + selectionManager.textSelections.map { selection in + textStorage.mutableString.rangeOfComposedCharacterSequences(for: selection.range) as NSValue + } + } + + override open func accessibilityInsertionPointLineNumber() -> Int { + guard let selection = selectionManager + .textSelections + .sorted(by: { $0.range.lowerBound < $1.range.lowerBound }) + .first, + let linePosition = layoutManager.textLineForOffset(selection.range.location) else { + return 0 + } + return linePosition.index + } + + override open func setAccessibilitySelectedTextRange(_ accessibilitySelectedTextRange: NSRange) { + selectionManager.setSelectedRange(accessibilitySelectedTextRange) + } + + override open func setAccessibilitySelectedTextRanges(_ accessibilitySelectedTextRanges: [NSValue]?) { + let ranges = accessibilitySelectedTextRanges?.compactMap { $0 as? NSRange } ?? [] + selectionManager.setSelectedRanges(ranges) + } + + // MARK: Text Ranges + + override open func accessibilityNumberOfCharacters() -> Int { + string.count + } + + override open func accessibilityRange(forLine line: Int) -> NSRange { + guard line >= 0 && layoutManager.lineStorage.count > line, + let linePosition = layoutManager.textLineForIndex(line) else { + return .zero + } + return linePosition.range + } + + override open func accessibilityRange(for point: NSPoint) -> NSRange { + guard let location = layoutManager.textOffsetAtPoint(point) else { return .zero } + return NSRange(location: location, length: 0) + } + + override open func accessibilityRange(for index: Int) -> NSRange { + textStorage.mutableString.rangeOfComposedCharacterSequence(at: index) + } +} From 181e7ff7e8a3d38c063a969f589a445d7c3a2fad Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 14 Oct 2023 16:13:24 -0500 Subject: [PATCH 53/75] Fix Linter --- .../Controller/TextViewController.swift | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/Sources/CodeEditTextView/Controller/TextViewController.swift b/Sources/CodeEditTextView/Controller/TextViewController.swift index e319daded..b589d4e67 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController.swift @@ -117,9 +117,7 @@ public class TextViewController: NSViewController { internal var storageDelegate: MultiStorageDelegate! internal var highlighter: Highlighter? - private var fontCharWidth: CGFloat { - (" " as NSString).size(withAttributes: [.font: font]).width - } + private var fontCharWidth: CGFloat { (" " as NSString).size(withAttributes: [.font: font]).width } /// Filters used when applying edits.. internal var textFilters: [TextFormation.Filter] = [] @@ -127,9 +125,7 @@ public class TextViewController: NSViewController { /// The pixel value to overscroll the bottom of the editor. /// Calculated as the line height \* ``TextViewController/editorOverscroll``. /// Does not include ``TextViewController/contentInsets``. - private var bottomContentInset: CGFloat { - (textView?.estimatedLineHeight() ?? 0) * CGFloat(editorOverscroll) - } + private var bottomContentInset: CGFloat { (textView?.estimatedLineHeight() ?? 0) * CGFloat(editorOverscroll) } private var cancellables = Set() @@ -238,7 +234,6 @@ public class TextViewController: NSViewController { ) self.view = scrollView - setUpHighlighter() NSLayoutConstraint.activate([ @@ -340,7 +335,6 @@ public class TextViewController: NSViewController { } highlighter?.invalidate() - // highlightSelectionPairs() } deinit { From 5a11adaa1eaa0a2c9c74c54435c1a59f3421dba1 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 14 Oct 2023 20:06:32 -0500 Subject: [PATCH 54/75] Fix Editing Bugs, Pass Tests --- .../TextLayoutManager+Edits.swift | 10 +- .../TextLayoutManager+Public.swift | 2 +- .../TextLineStorage/TextLineStorage.swift | 6 +- .../CodeEditInputView/TextView/TextView.swift | 14 +- .../TextView/TextViewDelegate.swift | 12 +- .../CodeEditTextView/CodeEditTextView.swift | 2 +- .../TextViewController+Cursor.swift | 7 +- .../TextViewController+LoadView.swift | 118 +++++++ .../TextViewController+TextViewDelegate.swift | 26 ++ .../Controller/TextViewController.swift | 149 ++------- .../CodeEditTextView/Enums/CaptureName.swift | 2 +- .../Extensions/TextView+/TextView+Menu.swift | 2 +- .../TextView+/TextView+TextFormation.swift | 2 +- .../Filters/DeleteWhitespaceFilter.swift | 9 +- .../Highlighting/HighlightProviding.swift | 1 - .../Highlighting/Highlighter.swift | 2 +- .../STTextViewControllerTests.swift | 289 ----------------- .../TextViewControllerTests.swift | 296 ++++++++++++++++++ 18 files changed, 493 insertions(+), 456 deletions(-) create mode 100644 Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift create mode 100644 Sources/CodeEditTextView/Controller/TextViewController+TextViewDelegate.swift delete mode 100644 Tests/CodeEditTextViewTests/STTextViewControllerTests.swift create mode 100644 Tests/CodeEditTextViewTests/TextViewControllerTests.swift diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift index fe3c94de5..52218307f 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift @@ -66,7 +66,15 @@ extension TextLayoutManager: NSTextStorageDelegate { /// - insertedString: The string being inserted. /// - location: The location the string is being inserted into. private func applyLineInsert(_ insertedString: NSString, at location: Int) { - if LineEnding(line: insertedString as String) != nil { + if lineStorage.count == 0 && lineStorage.length == 0 { + // The text was completely empty before, insert. + lineStorage.insert( + line: TextLine(), + atIndex: location, + length: insertedString.length, + height: estimateLineHeight() + ) + } else if LineEnding(line: insertedString as String) != nil { // Need to split the line inserting into and create a new line with the split section of the line guard let linePosition = lineStorage.getLine(atIndex: location) else { return } let splitLocation = location + insertedString.length diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift index 238f0e0b2..51dd075fb 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift @@ -29,7 +29,7 @@ extension TextLayoutManager { /// - Parameter index: The line to find. /// - Returns: The text line position if any, `nil` if the index is out of bounds. public func textLineForIndex(_ index: Int) -> TextLineStorage.TextLinePosition? { - guard index > 0 && index < lineStorage.count else { return nil } + guard index >= 0 && index < lineStorage.count else { return nil } return lineStorage.getLine(atIndex: index) } diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift index 991845fee..f9f8f61cd 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift @@ -486,9 +486,11 @@ private extension TextLineStorage { deltaHeight: CGFloat, nodeAction: MetaFixupAction = .none ) { - guard node.parent != nil else { return } + guard node.parent != nil, root != nil else { return } + let rootRef = Unmanaged>.passUnretained(root!) var ref = Unmanaged>.passUnretained(node) - while let node = ref._withUnsafeGuaranteedRef({ $0.parent }), ref.takeUnretainedValue() !== root { + while let node = ref._withUnsafeGuaranteedRef({ $0.parent }), + ref.takeUnretainedValue() !== rootRef.takeUnretainedValue() { if node.left === ref.takeUnretainedValue() { node.leftSubtreeOffset += delta node.leftSubtreeHeight += deltaHeight diff --git a/Sources/CodeEditInputView/TextView/TextView.swift b/Sources/CodeEditInputView/TextView/TextView.swift index 02451bce6..34ac1a0ff 100644 --- a/Sources/CodeEditInputView/TextView/TextView.swift +++ b/Sources/CodeEditInputView/TextView/TextView.swift @@ -44,6 +44,7 @@ public class TextView: NSView, NSTextContent { textStorage.string } set { + layoutManager.willReplaceCharactersInRange(range: documentRange, with: newValue) textStorage.setAttributedString(NSAttributedString(string: newValue, attributes: typingAttributes)) } } @@ -95,12 +96,6 @@ public class TextView: NSView, NSTextContent { layoutManager?.wrapLines = newValue } } - public var editorOverscroll: CGFloat { - didSet { - setNeedsDisplay() - updateFrameIfNeeded() - } - } /// A multiplier that determines the amount of space between characters. `1.0` indicates no space, /// `2.0` indicates one character of space between other characters. @@ -188,7 +183,6 @@ public class TextView: NSView, NSTextContent { textColor: NSColor, lineHeight: CGFloat, wrapLines: Bool, - editorOverscroll: CGFloat, isEditable: Bool, letterSpacing: Double, delegate: TextViewDelegate, @@ -197,8 +191,6 @@ public class TextView: NSView, NSTextContent { self.delegate = delegate self.textStorage = NSTextStorage(string: string) self.storageDelegate = storageDelegate - - self.editorOverscroll = editorOverscroll self.isEditable = isEditable self.letterSpacing = letterSpacing self.allowsUndo = true @@ -399,8 +391,8 @@ public class TextView: NSView, NSTextContent { var didUpdate = false - if newHeight + editorOverscroll >= availableSize.height && frame.size.height != newHeight + editorOverscroll { - frame.size.height = newHeight + editorOverscroll + if newHeight >= availableSize.height && frame.size.height != newHeight { + frame.size.height = newHeight // No need to update layout after height adjustment } diff --git a/Sources/CodeEditInputView/TextView/TextViewDelegate.swift b/Sources/CodeEditInputView/TextView/TextViewDelegate.swift index 36c03eefe..b977408c4 100644 --- a/Sources/CodeEditInputView/TextView/TextViewDelegate.swift +++ b/Sources/CodeEditInputView/TextView/TextViewDelegate.swift @@ -8,13 +8,13 @@ import Foundation public protocol TextViewDelegate: AnyObject { - func textView(_ textView: TextView, willReplaceContentsIn range: NSRange, with: String) - func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with: String) - func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with: String) -> Bool + func textView(_ textView: TextView, willReplaceContentsIn range: NSRange, with string: String) + func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) + func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with string: String) -> Bool } public extension TextViewDelegate { - func textView(_ textView: TextView, willReplaceContentsIn range: NSRange, with: String) { } - func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with: String) { } - func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with: String) -> Bool { true } + func textView(_ textView: TextView, willReplaceContentsIn range: NSRange, with string: String) { } + func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) { } + func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with string: String) -> Bool { true } } diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index 0ac71abd9..db9cf1b30 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditTextView/CodeEditTextView.swift @@ -87,7 +87,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { private var letterSpacing: Double private var bracketPairHighlight: BracketPairHighlight? - public typealias NSViewControllerType = TextViewController // STTextViewController + public typealias NSViewControllerType = TextViewController public func makeNSViewController(context: Context) -> TextViewController { return TextViewController( diff --git a/Sources/CodeEditTextView/Controller/TextViewController+Cursor.swift b/Sources/CodeEditTextView/Controller/TextViewController+Cursor.swift index 0d5a1371d..8f5247b47 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController+Cursor.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController+Cursor.swift @@ -13,14 +13,15 @@ extension TextViewController { /// - Parameter position: The position to set. Lines and columns are 1-indexed. func setCursorPosition(_ position: (Int, Int)) { let (line, column) = position - guard line >= 0 && column >= 0 else { return } + guard line > 0 && column > 0 else { return } + + _ = textView.becomeFirstResponder() if textView.textStorage.length == 0 { // If the file is blank, automatically place the cursor in the first index. let range = NSRange(location: 0, length: 0) - _ = self.textView.becomeFirstResponder() self.textView.selectionManager.setSelectedRange(range) - } else if line - 1 >= 0, let linePosition = textView.layoutManager.textLineForIndex(line - 1) { + } else if let linePosition = textView.layoutManager.textLineForIndex(line - 1) { // If this is a valid line, set the new position let index = max( linePosition.range.lowerBound, diff --git a/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift b/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift new file mode 100644 index 000000000..1503ce332 --- /dev/null +++ b/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift @@ -0,0 +1,118 @@ +// +// TextViewController+LoadView.swift +// +// +// Created by Khan Winter on 10/14/23. +// + +import CodeEditInputView +import AppKit + +extension TextViewController { + // swiftlint:disable:next function_body_length + override public func loadView() { + scrollView = NSScrollView() + textView = TextView( + string: string.wrappedValue, + font: font, + textColor: theme.text, + lineHeight: lineHeightMultiple, + wrapLines: wrapLines, + isEditable: isEditable, + letterSpacing: letterSpacing, + delegate: self, + storageDelegate: storageDelegate + ) + textView.postsFrameChangedNotifications = true + textView.translatesAutoresizingMaskIntoConstraints = false + textView.selectionManager.insertionPointColor = theme.insertionPoint + + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.contentView.postsFrameChangedNotifications = true + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = true + scrollView.documentView = textView + scrollView.contentView.postsBoundsChangedNotifications = true + if let contentInsets { + scrollView.automaticallyAdjustsContentInsets = false + scrollView.contentInsets = contentInsets + } + + gutterView = GutterView( + font: font.rulerFont, + textColor: .secondaryLabelColor, + textView: textView, + delegate: self + ) + gutterView.frame.origin.y = -scrollView.contentInsets.top + gutterView.updateWidthIfNeeded() + scrollView.addFloatingSubview( + gutterView, + for: .horizontal + ) + + self.view = scrollView + setUpHighlighter() + + 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) + ]) + + // Layout on scroll change + NotificationCenter.default.addObserver( + forName: NSView.boundsDidChangeNotification, + object: scrollView.contentView, + queue: .main + ) { [weak self] _ in + self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero) + self?.gutterView.needsDisplay = true + } + + // Layout on frame change + NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: scrollView.contentView, + queue: .main + ) { [weak self] _ in + self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero) + self?.gutterView.needsDisplay = true + if self?.bracketPairHighlight == .flash { + self?.removeHighlightLayers() + } + } + + NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: textView, + queue: .main + ) { [weak self] _ in + self?.gutterView.frame.size.height = (self?.textView.frame.height ?? 0) + 10 + self?.gutterView.needsDisplay = true + } + + NotificationCenter.default.addObserver( + forName: TextSelectionManager.selectionChangedNotification, + object: textView.selectionManager, + queue: .main + ) { [weak self] _ in + self?.updateCursorPosition() + self?.highlightSelectionPairs() + } + + textView.updateFrameIfNeeded() + + NSApp.publisher(for: \.effectiveAppearance) + .receive(on: RunLoop.main) + .sink { [weak self] newValue in + guard let self = self else { return } + + if self.systemAppearance != newValue.name { + self.systemAppearance = newValue.name + } + } + .store(in: &cancellables) + } +} diff --git a/Sources/CodeEditTextView/Controller/TextViewController+TextViewDelegate.swift b/Sources/CodeEditTextView/Controller/TextViewController+TextViewDelegate.swift new file mode 100644 index 000000000..667489ec4 --- /dev/null +++ b/Sources/CodeEditTextView/Controller/TextViewController+TextViewDelegate.swift @@ -0,0 +1,26 @@ +// +// TextViewController+TextViewDelegate.swift +// +// +// Created by Khan Winter on 10/14/23. +// + +import Foundation +import CodeEditInputView +import TextStory + +extension TextViewController: TextViewDelegate { + public func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with: String) { + gutterView.needsDisplay = true + } + + public func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with string: String) -> Bool { + let mutation = TextMutation( + string: string, + range: range, + limit: textView.textStorage.length + ) + + return shouldApplyMutation(mutation, to: textView) + } +} diff --git a/Sources/CodeEditTextView/Controller/TextViewController.swift b/Sources/CodeEditTextView/Controller/TextViewController.swift index b589d4e67..ab9a898fa 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController.swift @@ -19,7 +19,7 @@ public class TextViewController: NSViewController { var gutterView: GutterView! /// Internal reference to any injected layers in the text view. internal var highlightLayers: [CALayer] = [] - private var systemAppearance: NSAppearance.Name? + internal var systemAppearance: NSAppearance.Name? /// Binding for the `textView`s string public var string: Binding @@ -76,12 +76,11 @@ public class TextViewController: NSViewController { /// The current cursor position e.g. (1, 1) public var cursorPosition: Binding<(Int, Int)> - /// The height to overscroll the textview by. - public var editorOverscroll: CGFloat { - didSet { - textView.editorOverscroll = editorOverscroll - } - } + /// The editorOverscroll to use for the textView over scroll + /// + /// Measured in a percentage of the view's total height, meaning a `0.3` value will result in overscroll + /// of 1/3 of the view. + public var editorOverscroll: CGFloat /// Whether the code editor should use the theme background color or be transparent public var useThemeBackground: Bool @@ -122,12 +121,19 @@ public class TextViewController: NSViewController { /// Filters used when applying edits.. internal var textFilters: [TextFormation.Filter] = [] - /// The pixel value to overscroll the bottom of the editor. - /// Calculated as the line height \* ``TextViewController/editorOverscroll``. - /// Does not include ``TextViewController/contentInsets``. - private var bottomContentInset: CGFloat { (textView?.estimatedLineHeight() ?? 0) * CGFloat(editorOverscroll) } + internal var cancellables = Set() + + /// ScrollView's bottom inset using as editor overscroll + private var bottomContentInsets: CGFloat { + let height = view.frame.height + var inset = editorOverscroll * height - private var cancellables = Set() + if height - inset < font.lineHeight * lineHeightMultiple { + inset = height - font.lineHeight * lineHeightMultiple + } + + return max(inset, .zero) + } // MARK: Init @@ -188,121 +194,10 @@ public class TextViewController: NSViewController { return paragraph } - // MARK: Load View - - // swiftlint:disable:next function_body_length - override public func loadView() { - scrollView = NSScrollView() - textView = TextView( - string: string.wrappedValue, - font: font, - textColor: theme.text, - lineHeight: lineHeightMultiple, - wrapLines: wrapLines, - editorOverscroll: bottomContentInset, - isEditable: isEditable, - letterSpacing: letterSpacing, - delegate: self, - storageDelegate: storageDelegate - ) - textView.postsFrameChangedNotifications = true - textView.translatesAutoresizingMaskIntoConstraints = false - textView.selectionManager.insertionPointColor = theme.insertionPoint - - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.contentView.postsFrameChangedNotifications = true - scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = true - scrollView.documentView = textView - scrollView.contentView.postsBoundsChangedNotifications = true - if let contentInsets { - scrollView.automaticallyAdjustsContentInsets = false - scrollView.contentInsets = contentInsets - } - - gutterView = GutterView( - font: font.rulerFont, - textColor: .secondaryLabelColor, - textView: textView, - delegate: self - ) - gutterView.frame.origin.y = -scrollView.contentInsets.top - gutterView.updateWidthIfNeeded() - scrollView.addFloatingSubview( - gutterView, - for: .horizontal - ) - - self.view = scrollView - setUpHighlighter() - - 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) - ]) - - // Layout on scroll change - NotificationCenter.default.addObserver( - forName: NSView.boundsDidChangeNotification, - object: scrollView.contentView, - queue: .main - ) { [weak self] _ in - self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero) - self?.gutterView.needsDisplay = true - } - - // Layout on frame change - NotificationCenter.default.addObserver( - forName: NSView.frameDidChangeNotification, - object: scrollView.contentView, - queue: .main - ) { [weak self] _ in - self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero) - self?.gutterView.needsDisplay = true - if self?.bracketPairHighlight == .flash { - self?.removeHighlightLayers() - } - } - - NotificationCenter.default.addObserver( - forName: NSView.frameDidChangeNotification, - object: textView, - queue: .main - ) { [weak self] _ in - self?.gutterView.frame.size.height = (self?.textView.frame.height ?? 0) + 10 - self?.gutterView.needsDisplay = true - } - - NotificationCenter.default.addObserver( - forName: TextSelectionManager.selectionChangedNotification, - object: textView.selectionManager, - queue: .main - ) { [weak self] _ in - self?.updateCursorPosition() - self?.highlightSelectionPairs() - } - - textView.updateFrameIfNeeded() - - NSApp.publisher(for: \.effectiveAppearance) - .receive(on: RunLoop.main) - .sink { [weak self] newValue in - guard let self = self else { return } - - if self.systemAppearance != newValue.name { - self.systemAppearance = newValue.name - } - } - .store(in: &cancellables) - } - // MARK: - Reload UI func reloadUI() { textView.isEditable = isEditable - textView.editorOverscroll = bottomContentInset textView.selectionManager.selectionBackgroundColor = theme.selection textView.selectionManager.selectedLineBackgroundColor = useThemeBackground @@ -331,7 +226,7 @@ public class TextViewController: NSViewController { if let contentInsets = contentInsets { scrollView.contentInsets = contentInsets } - scrollView.contentInsets.bottom = bottomContentInset + (contentInsets?.bottom ?? 0) + scrollView.contentInsets.bottom = (contentInsets?.bottom ?? 0) + bottomContentInsets } highlighter?.invalidate() @@ -346,12 +241,6 @@ public class TextViewController: NSViewController { } } -extension TextViewController: TextViewDelegate { - public func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with: String) { - gutterView.needsDisplay = true - } -} - extension TextViewController: GutterViewDelegate { public func gutterViewWidthDidUpdate(newWidth: CGFloat) { gutterView?.frame.size.width = newWidth diff --git a/Sources/CodeEditTextView/Enums/CaptureName.swift b/Sources/CodeEditTextView/Enums/CaptureName.swift index 9c8599cad..0112782a8 100644 --- a/Sources/CodeEditTextView/Enums/CaptureName.swift +++ b/Sources/CodeEditTextView/Enums/CaptureName.swift @@ -1,5 +1,5 @@ // -// STTextViewController+CaptureNames.swift +// CaptureNames.swift // CodeEditTextView // // Created by Lukas Pistrol on 16.08.22. diff --git a/Sources/CodeEditTextView/Extensions/TextView+/TextView+Menu.swift b/Sources/CodeEditTextView/Extensions/TextView+/TextView+Menu.swift index 576380c51..2baa4b200 100644 --- a/Sources/CodeEditTextView/Extensions/TextView+/TextView+Menu.swift +++ b/Sources/CodeEditTextView/Extensions/TextView+/TextView+Menu.swift @@ -1,5 +1,5 @@ // -// STTextView+Menu.swift +// TextView+Menu.swift // CodeEditTextView // // Created by Lukas Pistrol on 25.05.22. diff --git a/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift b/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift index 612248472..3844ab86d 100644 --- a/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift +++ b/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift @@ -35,6 +35,6 @@ extension TextView: TextInterface { /// Applies the mutation to the text view. /// - Parameter mutation: The mutation to apply. public func applyMutation(_ mutation: TextMutation) { - replaceCharacters(in: mutation.range, with: mutation.string) + textStorage.replaceCharacters(in: mutation.range, with: mutation.string) } } diff --git a/Sources/CodeEditTextView/Filters/DeleteWhitespaceFilter.swift b/Sources/CodeEditTextView/Filters/DeleteWhitespaceFilter.swift index a5f92579b..bfac27860 100644 --- a/Sources/CodeEditTextView/Filters/DeleteWhitespaceFilter.swift +++ b/Sources/CodeEditTextView/Filters/DeleteWhitespaceFilter.swift @@ -33,7 +33,8 @@ struct DeleteWhitespaceFilter: Filter { in interface: TextInterface, with providers: WhitespaceProviders ) -> FilterAction { - guard mutation.string == "" + guard mutation.delta < 0 + && mutation.string == "" && mutation.range.length == 1 && indentOption != .tab else { return .none @@ -59,12 +60,6 @@ struct DeleteWhitespaceFilter: Filter { ) ) - if let textView = interface as? TextView, textView.selectionManager.textSelections.count == 1 { - textView.selectionManager.setSelectedRange( - NSRange(location: leadingWhitespace.max - numberOfExtraSpaces, length: 0) - ) - } - return .discard } } diff --git a/Sources/CodeEditTextView/Highlighting/HighlightProviding.swift b/Sources/CodeEditTextView/Highlighting/HighlightProviding.swift index 88ed36ea8..9ac761987 100644 --- a/Sources/CodeEditTextView/Highlighting/HighlightProviding.swift +++ b/Sources/CodeEditTextView/Highlighting/HighlightProviding.swift @@ -7,7 +7,6 @@ import Foundation import CodeEditLanguages -import STTextView import AppKit /// The protocol a class must conform to to be used for highlighting. diff --git a/Sources/CodeEditTextView/Highlighting/Highlighter.swift b/Sources/CodeEditTextView/Highlighting/Highlighter.swift index c7ee5ed2f..a2485d7e3 100644 --- a/Sources/CodeEditTextView/Highlighting/Highlighter.swift +++ b/Sources/CodeEditTextView/Highlighting/Highlighter.swift @@ -11,7 +11,7 @@ import CodeEditInputView import SwiftTreeSitter import CodeEditLanguages -/// The `Highlighter` class handles efficiently highlighting the `STTextView` it's provided with. +/// The `Highlighter` class handles efficiently highlighting the `TextView` it's provided with. /// It will listen for text and visibility changes, and highlight syntax as needed. /// /// One should rarely have to direcly modify or call methods on this class. Just keep it alive in diff --git a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift b/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift deleted file mode 100644 index 5f2f28b78..000000000 --- a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift +++ /dev/null @@ -1,289 +0,0 @@ -import XCTest -@testable import CodeEditTextView -import SwiftTreeSitter -import AppKit -import TextStory - -// swiftlint:disable all -final class STTextViewControllerTests: XCTestCase { - -// var controller: STTextViewController! -// var theme: EditorTheme! -// -// override func setUpWithError() throws { -// theme = EditorTheme( -// text: .textColor, -// insertionPoint: .textColor, -// invisibles: .gray, -// background: .textBackgroundColor, -// lineHighlight: .highlightColor, -// selection: .selectedTextColor, -// keywords: .systemPink, -// commands: .systemBlue, -// types: .systemMint, -// attributes: .systemTeal, -// variables: .systemCyan, -// values: .systemOrange, -// numbers: .systemYellow, -// strings: .systemRed, -// characters: .systemRed, -// comments: .systemGreen -// ) -// controller = STTextViewController( -// text: .constant(""), -// language: .default, -// font: .monospacedSystemFont(ofSize: 11, weight: .medium), -// theme: theme, -// tabWidth: 4, -// indentOption: .spaces(count: 4), -// lineHeight: 1.0, -// wrapLines: true, -// cursorPosition: .constant((1, 1)), -// editorOverscroll: 0.5, -// useThemeBackground: true, -// isEditable: true, -// letterSpacing: 1.0 -// ) -// -// controller.loadView() -// } -// -// func test_captureNames() throws { -// // test for "keyword" -// let captureName1 = "keyword" -// let color1 = controller.attributesFor(CaptureName(rawValue: captureName1))[.foregroundColor] as? NSColor -// XCTAssertEqual(color1, NSColor.systemPink) -// -// // test for "comment" -// let captureName2 = "comment" -// let color2 = controller.attributesFor(CaptureName(rawValue: captureName2))[.foregroundColor] as? NSColor -// XCTAssertEqual(color2, NSColor.systemGreen) -// -// /* ... additional tests here ... */ -// -// // test for empty case -// let captureName3 = "" -// let color3 = controller.attributesFor(CaptureName(rawValue: captureName3))[.foregroundColor] as? NSColor -// XCTAssertEqual(color3, NSColor.textColor) -// -// // test for random case -// let captureName4 = "abc123" -// let color4 = controller.attributesFor(CaptureName(rawValue: captureName4))[.foregroundColor] as? NSColor -// XCTAssertEqual(color4, NSColor.textColor) -// } -// -// func test_editorOverScroll() throws { -// let scrollView = try XCTUnwrap(controller.view as? NSScrollView) -// scrollView.frame = .init(x: .zero, -// y: .zero, -// width: 100, -// height: 100) -// -// controller.editorOverscroll = 0 -// controller.contentInsets = nil -// controller.reloadUI() -// -// // editorOverscroll: 0 -// XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 0) -// -// controller.editorOverscroll = 0.5 -// controller.reloadUI() -// -// // editorOverscroll: 0.5 -// XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 50.0) -// -// controller.editorOverscroll = 1.0 -// controller.reloadUI() -// -// // editorOverscroll: 1.0 -// XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 87.0) -// } -// -// func test_editorInsets() throws { -// let scrollView = try XCTUnwrap(controller.view as? NSScrollView) -// scrollView.frame = .init(x: .zero, -// y: .zero, -// width: 100, -// height: 100) -// -// func assertInsetsEqual(_ lhs: NSEdgeInsets, _ rhs: NSEdgeInsets) throws { -// XCTAssertEqual(lhs.top, rhs.top) -// XCTAssertEqual(lhs.right, rhs.right) -// XCTAssertEqual(lhs.bottom, rhs.bottom) -// XCTAssertEqual(lhs.left, rhs.left) -// } -// -// controller.editorOverscroll = 0 -// controller.contentInsets = nil -// controller.reloadUI() -// -// // contentInsets: 0 -// try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)) -// -// // contentInsets: 16 -// controller.contentInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) -// controller.reloadUI() -// -// try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)) -// -// // contentInsets: different -// controller.contentInsets = NSEdgeInsets(top: 32.5, left: 12.3, bottom: 20, right: 1) -// controller.reloadUI() -// -// try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 32.5, left: 12.3, bottom: 20, right: 1)) -// -// // contentInsets: 16 -// // editorOverscroll: 0.5 -// controller.contentInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) -// controller.editorOverscroll = 0.5 -// controller.reloadUI() -// -// try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 16, left: 16, bottom: 16 + 50, right: 16)) -// } -// -// func test_editorOverScroll_ZeroCondition() throws { -// let scrollView = try XCTUnwrap(controller.view as? NSScrollView) -// scrollView.frame = .zero -// -// // editorOverscroll: 0 -// XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 0) -// } -// -// func test_indentOptionString() { -// XCTAssertEqual(" ", IndentOption.spaces(count: 1).stringValue) -// XCTAssertEqual(" ", IndentOption.spaces(count: 2).stringValue) -// XCTAssertEqual(" ", IndentOption.spaces(count: 3).stringValue) -// XCTAssertEqual(" ", IndentOption.spaces(count: 4).stringValue) -// XCTAssertEqual(" ", IndentOption.spaces(count: 5).stringValue) -// -// XCTAssertEqual("\t", IndentOption.tab.stringValue) -// } -// -// func test_indentBehavior() { -// // Insert 1 space -// controller.indentOption = .spaces(count: 1) -// controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") -// controller.insertTab(nil) -// XCTAssertEqual(controller.textView.string, " ") -// -// // Insert 2 spaces -// controller.indentOption = .spaces(count: 2) -// controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") -// controller.textView.insertText("\t", replacementRange: .zero) -// XCTAssertEqual(controller.textView.string, " ") -// -// // Insert 3 spaces -// controller.indentOption = .spaces(count: 3) -// controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") -// controller.textView.insertText("\t", replacementRange: .zero) -// XCTAssertEqual(controller.textView.string, " ") -// -// // Insert 4 spaces -// controller.indentOption = .spaces(count: 4) -// controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") -// controller.textView.insertText("\t", replacementRange: .zero) -// XCTAssertEqual(controller.textView.string, " ") -// -// // Insert tab -// controller.indentOption = .tab -// controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") -// controller.textView.insertText("\t", replacementRange: .zero) -// XCTAssertEqual(controller.textView.string, "\t") -// -// // Insert lots of spaces -// controller.indentOption = .spaces(count: 1000) -// controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") -// controller.textView.insertText("\t", replacementRange: .zero) -// XCTAssertEqual(controller.textView.string, String(repeating: " ", count: 1000)) -// } -// -// func test_letterSpacing() { -// let font: NSFont = .monospacedSystemFont(ofSize: 11, weight: .medium) -// -// controller.letterSpacing = 1.0 -// -// XCTAssertEqual( -// controller.attributesFor(nil)[.kern]! as! CGFloat, -// (" " as NSString).size(withAttributes: [.font: font]).width * 0.0 -// ) -// -// controller.letterSpacing = 2.0 -// XCTAssertEqual( -// controller.attributesFor(nil)[.kern]! as! CGFloat, -// (" " as NSString).size(withAttributes: [.font: font]).width * 1.0 -// ) -// -// controller.letterSpacing = 1.0 -// } -// -// func test_bracketHighlights() { -// controller.viewDidLoad() -// controller.bracketPairHighlight = nil -// controller.textView.string = "{ Loren Ipsum {} }" -// controller.setCursorPosition((1, 2)) // After first opening { -// XCTAssert(controller.highlightLayers.isEmpty, "Controller added highlight layer when setting is set to `nil`") -// controller.setCursorPosition((1, 3)) -// -// controller.bracketPairHighlight = .bordered(color: .black) -// controller.setCursorPosition((1, 2)) // After first opening { -// XCTAssert(controller.highlightLayers.count == 2, "Controller created an incorrect number of layers for bordered. Expected 2, found \(controller.highlightLayers.count)") -// controller.setCursorPosition((1, 3)) -// XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") -// -// controller.bracketPairHighlight = .underline(color: .black) -// controller.setCursorPosition((1, 2)) // After first opening { -// XCTAssert(controller.highlightLayers.count == 2, "Controller created an incorrect number of layers for underline. Expected 2, found \(controller.highlightLayers.count)") -// controller.setCursorPosition((1, 3)) -// XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") -// -// controller.bracketPairHighlight = .flash -// controller.setCursorPosition((1, 2)) // After first opening { -// XCTAssert(controller.highlightLayers.count == 1, "Controller created more than one layer for flash animation. Expected 1, found \(controller.highlightLayers.count)") -// controller.setCursorPosition((1, 3)) -// XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") -// -// controller.setCursorPosition((1, 2)) // After first opening { -// XCTAssert(controller.highlightLayers.count == 1, "Controller created more than one layer for flash animation. Expected 1, found \(controller.highlightLayers.count)") -// let exp = expectation(description: "Test after 0.8 seconds") -// let result = XCTWaiter.wait(for: [exp], timeout: 0.8) -// if result == XCTWaiter.Result.timedOut { -// XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove layer after flash animation. Expected 0, found \(controller.highlightLayers.count)") -// } else { -// XCTFail("Delay interrupted") -// } -// } -// -// func test_findClosingPair() { -// controller.textView.string = "{ Loren Ipsum {} }" -// var idx: Int? -// -// // Test walking forwards -// idx = controller.findClosingPair("{", "}", from: 1, limit: 18, reverse: false) -// XCTAssert(idx == 17, "Walking forwards failed. Expected `17`, found: `\(String(describing: idx))`") -// -// // Test walking backwards -// idx = controller.findClosingPair("}", "{", from: 17, limit: 0, reverse: true) -// XCTAssert(idx == 0, "Walking backwards failed. Expected `0`, found: `\(String(describing: idx))`") -// -// // Test extra pair -// controller.textView.string = "{ Loren Ipsum {}} }" -// idx = controller.findClosingPair("{", "}", from: 1, limit: 19, reverse: false) -// XCTAssert(idx == 16, "Walking forwards with extra bracket pair failed. Expected `16`, found: `\(String(describing: idx))`") -// -// // Text extra pair backwards -// controller.textView.string = "{ Loren Ipsum {{} }" -// idx = controller.findClosingPair("}", "{", from: 18, limit: 0, reverse: true) -// XCTAssert(idx == 14, "Walking backwards with extra bracket pair failed. Expected `14`, found: `\(String(describing: idx))`") -// -// // Test missing pair -// controller.textView.string = "{ Loren Ipsum { }" -// idx = controller.findClosingPair("{", "}", from: 1, limit: 17, reverse: false) -// XCTAssert(idx == nil, "Walking forwards with missing pair failed. Expected `nil`, found: `\(String(describing: idx))`") -// -// // Test missing pair backwards -// controller.textView.string = " Loren Ipsum {} }" -// idx = controller.findClosingPair("}", "{", from: 17, limit: 0, reverse: true) -// XCTAssert(idx == nil, "Walking backwards with missing pair failed. Expected `nil`, found: `\(String(describing: idx))`") -// } -} -// swiftlint:enable all diff --git a/Tests/CodeEditTextViewTests/TextViewControllerTests.swift b/Tests/CodeEditTextViewTests/TextViewControllerTests.swift new file mode 100644 index 000000000..bbbde3267 --- /dev/null +++ b/Tests/CodeEditTextViewTests/TextViewControllerTests.swift @@ -0,0 +1,296 @@ +import XCTest +@testable import CodeEditTextView +import SwiftTreeSitter +import AppKit +import SwiftUI +import TextStory + +// swiftlint:disable all +final class TextViewControllerTests: XCTestCase { + + var controller: TextViewController! + var theme: EditorTheme! + + override func setUpWithError() throws { + theme = EditorTheme( + text: .textColor, + insertionPoint: .textColor, + invisibles: .gray, + background: .textBackgroundColor, + lineHighlight: .highlightColor, + selection: .selectedTextColor, + keywords: .systemPink, + commands: .systemBlue, + types: .systemMint, + attributes: .systemTeal, + variables: .systemCyan, + values: .systemOrange, + numbers: .systemYellow, + strings: .systemRed, + characters: .systemRed, + comments: .systemGreen + ) + controller = TextViewController( + string: .constant(""), + language: .default, + font: .monospacedSystemFont(ofSize: 11, weight: .medium), + theme: theme, + tabWidth: 4, + indentOption: .spaces(count: 4), + lineHeight: 1.0, + wrapLines: true, + cursorPosition: .constant((1, 1)), + editorOverscroll: 0.5, + useThemeBackground: true, + highlightProvider: nil, + contentInsets: NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0), + isEditable: true, + letterSpacing: 1.0, + bracketPairHighlight: .flash + ) + + controller.loadView() + } + + func test_captureNames() throws { + // test for "keyword" + let captureName1 = "keyword" + let color1 = controller.attributesFor(CaptureName(rawValue: captureName1))[.foregroundColor] as? NSColor + XCTAssertEqual(color1, NSColor.systemPink) + + // test for "comment" + let captureName2 = "comment" + let color2 = controller.attributesFor(CaptureName(rawValue: captureName2))[.foregroundColor] as? NSColor + XCTAssertEqual(color2, NSColor.systemGreen) + + /* ... additional tests here ... */ + + // test for empty case + let captureName3 = "" + let color3 = controller.attributesFor(CaptureName(rawValue: captureName3))[.foregroundColor] as? NSColor + XCTAssertEqual(color3, NSColor.textColor) + + // test for random case + let captureName4 = "abc123" + let color4 = controller.attributesFor(CaptureName(rawValue: captureName4))[.foregroundColor] as? NSColor + XCTAssertEqual(color4, NSColor.textColor) + } + + func test_editorOverScroll() throws { + let scrollView = try XCTUnwrap(controller.view as? NSScrollView) + scrollView.frame = .init(x: .zero, + y: .zero, + width: 100, + height: 100) + + controller.editorOverscroll = 0 + controller.contentInsets = nil + controller.reloadUI() + + // editorOverscroll: 0 + XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 0) + + controller.editorOverscroll = 0.5 + controller.reloadUI() + + // editorOverscroll: 0.5 + XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 50.0) + + controller.editorOverscroll = 1.0 + controller.reloadUI() + + // editorOverscroll: 1.0 + XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 87.0) + } + + func test_editorInsets() throws { + let scrollView = try XCTUnwrap(controller.view as? NSScrollView) + scrollView.frame = .init(x: .zero, + y: .zero, + width: 100, + height: 100) + + func assertInsetsEqual(_ lhs: NSEdgeInsets, _ rhs: NSEdgeInsets) throws { + XCTAssertEqual(lhs.top, rhs.top) + XCTAssertEqual(lhs.right, rhs.right) + XCTAssertEqual(lhs.bottom, rhs.bottom) + XCTAssertEqual(lhs.left, rhs.left) + } + + controller.editorOverscroll = 0 + controller.contentInsets = nil + controller.reloadUI() + + // contentInsets: 0 + try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)) + + // contentInsets: 16 + controller.contentInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + controller.reloadUI() + + try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)) + + // contentInsets: different + controller.contentInsets = NSEdgeInsets(top: 32.5, left: 12.3, bottom: 20, right: 1) + controller.reloadUI() + + try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 32.5, left: 12.3, bottom: 20, right: 1)) + + // contentInsets: 16 + // editorOverscroll: 0.5 + controller.contentInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + controller.editorOverscroll = 0.5 + controller.reloadUI() + + try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 16, left: 16, bottom: 16 + 50, right: 16)) + } + + func test_editorOverScroll_ZeroCondition() throws { + let scrollView = try XCTUnwrap(controller.view as? NSScrollView) + scrollView.frame = .zero + + // editorOverscroll: 0 + XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 0) + } + + func test_indentOptionString() { + XCTAssertEqual(" ", IndentOption.spaces(count: 1).stringValue) + XCTAssertEqual(" ", IndentOption.spaces(count: 2).stringValue) + XCTAssertEqual(" ", IndentOption.spaces(count: 3).stringValue) + XCTAssertEqual(" ", IndentOption.spaces(count: 4).stringValue) + XCTAssertEqual(" ", IndentOption.spaces(count: 5).stringValue) + + XCTAssertEqual("\t", IndentOption.tab.stringValue) + } + + func test_indentBehavior() { + // Insert 1 space + controller.indentOption = .spaces(count: 1) + controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textStorage.length), with: "") + controller.textView.selectionManager.setSelectedRange(NSRange(location: 0, length: 0)) + controller.textView.insertText("\t", replacementRange: .zero) + XCTAssertEqual(controller.textView.string, " ") + + // Insert 2 spaces + controller.indentOption = .spaces(count: 2) + controller.textView.textStorage.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textStorage.length), with: "") + controller.textView.insertText("\t", replacementRange: .zero) + XCTAssertEqual(controller.textView.string, " ") + + // Insert 3 spaces + controller.indentOption = .spaces(count: 3) + controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textStorage.length), with: "") + controller.textView.insertText("\t", replacementRange: .zero) + XCTAssertEqual(controller.textView.string, " ") + + // Insert 4 spaces + controller.indentOption = .spaces(count: 4) + controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textStorage.length), with: "") + controller.textView.insertText("\t", replacementRange: .zero) + XCTAssertEqual(controller.textView.string, " ") + + // Insert tab + controller.indentOption = .tab + controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textStorage.length), with: "") + controller.textView.insertText("\t", replacementRange: .zero) + XCTAssertEqual(controller.textView.string, "\t") + + // Insert lots of spaces + controller.indentOption = .spaces(count: 1000) + print(controller.textView.textStorage.length) + controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textStorage.length), with: "") + controller.textView.insertText("\t", replacementRange: .zero) + XCTAssertEqual(controller.textView.string, String(repeating: " ", count: 1000)) + } + + func test_letterSpacing() { + let font: NSFont = .monospacedSystemFont(ofSize: 11, weight: .medium) + + controller.letterSpacing = 1.0 + + XCTAssertEqual( + controller.attributesFor(nil)[.kern]! as! CGFloat, + (" " as NSString).size(withAttributes: [.font: font]).width * 0.0 + ) + + controller.letterSpacing = 2.0 + XCTAssertEqual( + controller.attributesFor(nil)[.kern]! as! CGFloat, + (" " as NSString).size(withAttributes: [.font: font]).width * 1.0 + ) + + controller.letterSpacing = 1.0 + } + + func test_bracketHighlights() { + controller.viewDidLoad() + controller.bracketPairHighlight = nil + controller.textView.string = "{ Loren Ipsum {} }" + controller.setCursorPosition((1, 2)) // After first opening { + XCTAssert(controller.highlightLayers.isEmpty, "Controller added highlight layer when setting is set to `nil`") + controller.setCursorPosition((1, 3)) + + controller.bracketPairHighlight = .bordered(color: .black) + controller.textView.setNeedsDisplay() + controller.setCursorPosition((1, 2)) // After first opening { + XCTAssert(controller.highlightLayers.count == 2, "Controller created an incorrect number of layers for bordered. Expected 2, found \(controller.highlightLayers.count)") + controller.setCursorPosition((1, 3)) + XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") + + controller.bracketPairHighlight = .underline(color: .black) + controller.setCursorPosition((1, 2)) // After first opening { + XCTAssert(controller.highlightLayers.count == 2, "Controller created an incorrect number of layers for underline. Expected 2, found \(controller.highlightLayers.count)") + controller.setCursorPosition((1, 3)) + XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") + + controller.bracketPairHighlight = .flash + controller.setCursorPosition((1, 2)) // After first opening { + XCTAssert(controller.highlightLayers.count == 1, "Controller created more than one layer for flash animation. Expected 1, found \(controller.highlightLayers.count)") + controller.setCursorPosition((1, 3)) + XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") + + controller.setCursorPosition((1, 2)) // After first opening { + XCTAssert(controller.highlightLayers.count == 1, "Controller created more than one layer for flash animation. Expected 1, found \(controller.highlightLayers.count)") + let exp = expectation(description: "Test after 0.8 seconds") + let result = XCTWaiter.wait(for: [exp], timeout: 0.8) + if result == XCTWaiter.Result.timedOut { + XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove layer after flash animation. Expected 0, found \(controller.highlightLayers.count)") + } else { + XCTFail("Delay interrupted") + } + } + + func test_findClosingPair() { + controller.textView.string = "{ Loren Ipsum {} }" + var idx: Int? + + // Test walking forwards + idx = controller.findClosingPair("{", "}", from: 1, limit: 18, reverse: false) + XCTAssert(idx == 17, "Walking forwards failed. Expected `17`, found: `\(String(describing: idx))`") + + // Test walking backwards + idx = controller.findClosingPair("}", "{", from: 17, limit: 0, reverse: true) + XCTAssert(idx == 0, "Walking backwards failed. Expected `0`, found: `\(String(describing: idx))`") + + // Test extra pair + controller.textView.string = "{ Loren Ipsum {}} }" + idx = controller.findClosingPair("{", "}", from: 1, limit: 19, reverse: false) + XCTAssert(idx == 16, "Walking forwards with extra bracket pair failed. Expected `16`, found: `\(String(describing: idx))`") + + // Text extra pair backwards + controller.textView.string = "{ Loren Ipsum {{} }" + idx = controller.findClosingPair("}", "{", from: 18, limit: 0, reverse: true) + XCTAssert(idx == 14, "Walking backwards with extra bracket pair failed. Expected `14`, found: `\(String(describing: idx))`") + + // Test missing pair + controller.textView.string = "{ Loren Ipsum { }" + idx = controller.findClosingPair("{", "}", from: 1, limit: 17, reverse: false) + XCTAssert(idx == nil, "Walking forwards with missing pair failed. Expected `nil`, found: `\(String(describing: idx))`") + + // Test missing pair backwards + controller.textView.string = " Loren Ipsum {} }" + idx = controller.findClosingPair("}", "{", from: 17, limit: 0, reverse: true) + XCTAssert(idx == nil, "Walking backwards with missing pair failed. Expected `nil`, found: `\(String(describing: idx))`") + } +} +// swiftlint:enable all From 5384b6b8abcb17ea53c87ca5cffb7c039220a13e Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 17 Oct 2023 12:52:57 -0500 Subject: [PATCH 55/75] Fix Basic Editing At End Of Files & New Files --- Package.swift | 2 +- .../TextLayoutManager+Edits.swift | 192 +++++++++++++++--- .../TextLayoutManager+Public.swift | 31 ++- .../TextLayoutManager/TextLayoutManager.swift | 43 +++- .../TextLine/LineFragment.swift | 5 +- .../TextLine/LineFragmentView.swift | 9 +- .../CodeEditInputView/TextLine/TextLine.swift | 35 +++- .../TextLine/Typesetter.swift | 81 +++++--- .../TextLineStorage+Iterator.swift | 3 +- .../TextLineStorage+NSTextStorage.swift | 19 +- .../TextLineStorage+Node.swift | 2 +- .../TextLineStorage+Structs.swift | 1 + .../TextLineStorage/TextLineStorage.swift | 80 ++++++-- ...lectionManager+SelectionManipulation.swift | 2 +- .../TextSelectionManager.swift | 9 +- .../CodeEditInputView/TextView/TextView.swift | 2 +- .../CodeEditTextView/Gutter/GutterView.swift | 2 +- .../TextLayoutLineStorageTests.swift | 11 +- 18 files changed, 410 insertions(+), 119 deletions(-) diff --git a/Package.swift b/Package.swift index cd2d34e88..8e52d7a75 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.7 +// swift-tools-version: 5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift index 52218307f..6b949a8ac 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift @@ -21,10 +21,10 @@ extension TextLayoutManager: NSTextStorageDelegate { /// - 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() { + 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 { + if intersection == linePosition.range && linePosition.range.max != lineStorage.length { // Delete line lineStorage.delete(lineAt: linePosition.range.location) } else if intersection.max == linePosition.range.max, @@ -57,7 +57,6 @@ extension TextLayoutManager: NSTextStorageDelegate { ) } } - setNeedsLayout() } @@ -66,30 +65,34 @@ extension TextLayoutManager: NSTextStorageDelegate { /// - insertedString: The string being inserted. /// - location: The location the string is being inserted into. private func applyLineInsert(_ insertedString: NSString, at location: Int) { - if lineStorage.count == 0 && lineStorage.length == 0 { - // The text was completely empty before, insert. - lineStorage.insert( - line: TextLine(), - atIndex: location, - length: insertedString.length, - height: estimateLineHeight() - ) - } else if LineEnding(line: insertedString as String) != nil { - // Need to split the line inserting into and create a new line with the split section of the line - guard let linePosition = lineStorage.getLine(atIndex: location) else { return } - let splitLocation = location + insertedString.length - let splitLength = linePosition.range.max - location - let lineDelta = insertedString.length - splitLength // The difference in the line being edited - if lineDelta != 0 { - lineStorage.update(atIndex: location, delta: lineDelta, deltaHeight: 0.0) - } + if LineEnding(line: insertedString as String) != nil { + if location == textStorage.length { + // Insert a new line at the end of the document, need to insert a new line 'cause there's nothing to + // split. Also, append the new text to the last line. + lineStorage.update(atIndex: location, delta: insertedString.length, deltaHeight: 0.0) + lineStorage.insert( + line: TextLine(), + asOffset: location + insertedString.length, + length: 0, + height: estimateLineHeight() + ) + } else { + // Need to split the line inserting into and create a new line with the split section of the line + guard let linePosition = lineStorage.getLine(atIndex: location) else { return } + let splitLocation = location + insertedString.length + let splitLength = linePosition.range.max - location + let lineDelta = insertedString.length - splitLength // The difference in the line being edited + if lineDelta != 0 { + lineStorage.update(atIndex: location, delta: lineDelta, deltaHeight: 0.0) + } - lineStorage.insert( - line: TextLine(), - atIndex: splitLocation, - length: splitLength, - height: estimateLineHeight() - ) + lineStorage.insert( + line: TextLine(), + asOffset: splitLocation, + length: splitLength, + height: estimateLineHeight() + ) + } } else { lineStorage.update(atIndex: location, delta: insertedString.length, deltaHeight: 0.0) } @@ -109,3 +112,140 @@ extension TextLayoutManager: NSTextStorageDelegate { } } } + +extension TextLineStorage { + var description: String { + treeString(root!) { + ("\($0.length)", $0.left, $0.right) + } + } +} + +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.. CGFloat { - lineStorage.height + max(lineStorage.height, estimateLineHeight()) } public func estimatedWidth() -> CGFloat { @@ -34,6 +34,9 @@ extension TextLayoutManager { } public func textOffsetAtPoint(_ point: CGPoint) -> Int? { + guard point.y <= estimatedHeight() else { // End position is a special case. + return textStorage.length + } guard let position = lineStorage.getLine(atPosition: point.y), let fragmentPosition = position.data.typesetter.lineFragments.getLine( atPosition: point.y - position.yPos @@ -69,6 +72,9 @@ extension TextLayoutManager { /// - Parameter offset: The offset to create the rect for. /// - Returns: The found rect for the given offset. public func rectForOffset(_ offset: Int) -> CGRect? { + guard offset != lineStorage.length else { + return rectForEndOffset() + } guard let linePosition = lineStorage.getLine(atIndex: offset) else { return nil } @@ -108,6 +114,28 @@ extension TextLayoutManager { ) } + /// Finds a suitable cursor rect for the end position. + /// - Returns: A CGRect if it could be created. + private func rectForEndOffset() -> CGRect? { + if let last = lineStorage.last { + if last.range.isEmpty { + // Return a 0-width rect at the end of the last line. + return CGRect(x: edgeInsets.left, y: last.yPos, width: 0, height: last.height) + } else if let rect = rectForOffset(last.range.max - 1) { + return CGRect(x: rect.maxX, y: rect.minY, width: 0, height: rect.height) + } + } else if lineStorage.isEmpty { + // Text is empty, create a new rect with estimated height at the origin + return CGRect( + x: edgeInsets.left, + y: 0.0, + width: 0, + height: estimateLineHeight() + ) + } + return nil + } + /// Forces layout calculation for all lines up to and including the given offset. /// - Parameter offset: The offset to ensure layout until. public func ensureLayoutUntil(_ offset: Int) { @@ -147,6 +175,7 @@ extension TextLayoutManager { position.data.prepareForDisplay( maxWidth: maxLineWidth, lineHeightMultiplier: lineHeightMultiplier, + estimatedLineHeight: estimateLineHeight(), range: position.range, stringRef: textStorage ) diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift index f95318244..2d871fa13 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift @@ -23,7 +23,11 @@ public class TextLayoutManager: NSObject { // MARK: - Public Properties public weak var delegate: TextLayoutManagerDelegate? - public var typingAttributes: [NSAttributedString.Key: Any] + public var typingAttributes: [NSAttributedString.Key: Any] { + didSet { + _estimateLineHeight = nil + } + } public var lineHeightMultiplier: CGFloat { didSet { setNeedsLayout() @@ -111,18 +115,30 @@ public class TextLayoutManager: NSObject { let nanos = elapsed * UInt64(info.numer) / UInt64(info.denom) print("Text Layout Manager built in: ", TimeInterval(nanos) / TimeInterval(NSEC_PER_MSEC), "ms") } - + + /// Estimates the line height for the current typing attributes. + /// Takes into account ``TextLayoutManager/lineHeightMultiplier``. + /// - Returns: The estimated line height. public 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 + if let _estimateLineHeight { + return _estimateLineHeight + } else { + 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) + _estimateLineHeight = (ascent + descent + leading) * lineHeightMultiplier + return _estimateLineHeight! + } } + /// The last known line height estimate. If set to `nil`, will be recalculated the next time + /// ``TextLayoutManager/estimateLineHeight()`` is called. + private var _estimateLineHeight: CGFloat? + // MARK: - Invalidation /// Invalidates layout for the given rect. @@ -253,11 +269,16 @@ public class TextLayoutManager: NSObject { let line = position.data line.prepareForDisplay( maxWidth: maxWidth, - lineHeightMultiplier: lineHeightMultiplier, + lineHeightMultiplier: lineHeightMultiplier, + estimatedLineHeight: estimateLineHeight(), range: position.range, stringRef: textStorage ) + if position.range.isEmpty { + return CGSize(width: 0, height: estimateLineHeight()) + } + var height: CGFloat = 0 var width: CGFloat = 0 diff --git a/Sources/CodeEditInputView/TextLine/LineFragment.swift b/Sources/CodeEditInputView/TextLine/LineFragment.swift index 756006e10..81776892c 100644 --- a/Sources/CodeEditInputView/TextLine/LineFragment.swift +++ b/Sources/CodeEditInputView/TextLine/LineFragment.swift @@ -7,6 +7,8 @@ import AppKit +/// A ``LineFragment`` represents a subrange of characters in a line. Every text line contains at least one line +/// fragments, and any lines that need to be broken due to width constraints will contain more than one fragment. public final class LineFragment: Identifiable, Equatable { public let id = UUID() private(set) public var ctLine: CTLine @@ -14,7 +16,8 @@ public final class LineFragment: Identifiable, Equatable { public let height: CGFloat public let descent: CGFloat public let scaledHeight: CGFloat - + + /// The difference between the real text height and the scaled height public var heightDifference: CGFloat { scaledHeight - height } diff --git a/Sources/CodeEditInputView/TextLine/LineFragmentView.swift b/Sources/CodeEditInputView/TextLine/LineFragmentView.swift index 255715ae2..d5ba4af0d 100644 --- a/Sources/CodeEditInputView/TextLine/LineFragmentView.swift +++ b/Sources/CodeEditInputView/TextLine/LineFragmentView.swift @@ -8,23 +8,28 @@ import AppKit import Common +/// Displays a line fragment. final class LineFragmentView: NSView { private weak var lineFragment: LineFragment? override var isFlipped: Bool { true } - + + /// Prepare the view for reuse, clears the line fragment reference. override func prepareForReuse() { super.prepareForReuse() lineFragment = nil } - + + /// Set a new line fragment for this view, updating view size. + /// - Parameter newFragment: The new fragment to use. public func setLineFragment(_ newFragment: LineFragment) { self.lineFragment = newFragment self.frame.size = CGSize(width: newFragment.width, height: newFragment.scaledHeight) } + /// Draws the line fragment in the graphics context. override func draw(_ dirtyRect: NSRect) { guard let lineFragment, let context = NSGraphicsContext.current?.cgContext else { return diff --git a/Sources/CodeEditInputView/TextLine/TextLine.swift b/Sources/CodeEditInputView/TextLine/TextLine.swift index 7287acd2c..42387b968 100644 --- a/Sources/CodeEditInputView/TextLine/TextLine.swift +++ b/Sources/CodeEditInputView/TextLine/TextLine.swift @@ -11,31 +11,50 @@ import AppKit /// Represents a displayable line of text. public final class TextLine: Identifiable, Equatable { public let id: UUID = UUID() -// private weak var stringRef: NSTextStorage? private var needsLayout: Bool = true var maxWidth: CGFloat? private(set) var typesetter: Typesetter = Typesetter() - + + /// The line fragments contained by this text line. public var lineFragments: TextLineStorage { typesetter.lineFragments } - - func setNeedsLayout() { + + /// Marks this line as needing layout and clears all typesetting data. + public func setNeedsLayout() { needsLayout = true typesetter = Typesetter() } - + + /// Determines if the line needs to be laid out again. + /// - Parameter maxWidth: The new max width to check. + /// - Returns: True, if this line has been marked as needing layout using ``TextLine/setNeedsLayout()`` or if the + /// line needs to find new line breaks due to a new constraining width. func needsLayout(maxWidth: CGFloat) -> Bool { needsLayout || maxWidth != self.maxWidth } - - func prepareForDisplay(maxWidth: CGFloat, lineHeightMultiplier: CGFloat, range: NSRange, stringRef: NSTextStorage) { + + /// Prepares the line for display, generating all potential line breaks and calculating the real height of the line. + /// - Parameters: + /// - maxWidth: The maximum width the line can be. Used to find line breaks. + /// - lineHeightMultiplier: The multiplier to use for lines. + /// - estimatedLineHeight: The estimated height for an empty line. + /// - range: The range this text range represents in the entire document. + /// - stringRef: A reference to the string storage for the document. + func prepareForDisplay( + maxWidth: CGFloat, + lineHeightMultiplier: CGFloat, + estimatedLineHeight: CGFloat, + range: NSRange, + stringRef: NSTextStorage + ) { let string = stringRef.attributedSubstring(from: range) self.maxWidth = maxWidth typesetter.prepareToTypeset( string, maxWidth: maxWidth, - lineHeightMultiplier: lineHeightMultiplier + lineHeightMultiplier: lineHeightMultiplier, + estimatedLineHeight: estimatedLineHeight ) needsLayout = false } diff --git a/Sources/CodeEditInputView/TextLine/Typesetter.swift b/Sources/CodeEditInputView/TextLine/Typesetter.swift index f0244f58f..47d94d467 100644 --- a/Sources/CodeEditInputView/TextLine/Typesetter.swift +++ b/Sources/CodeEditInputView/TextLine/Typesetter.swift @@ -17,39 +17,71 @@ final class Typesetter { init() { } - func prepareToTypeset(_ string: NSAttributedString, maxWidth: CGFloat, lineHeightMultiplier: CGFloat) { + func prepareToTypeset( + _ string: NSAttributedString, + maxWidth: CGFloat, + lineHeightMultiplier: CGFloat, + estimatedLineHeight: CGFloat + ) { lineFragments.removeAll() self.typesetter = CTTypesetterCreateWithAttributedString(string) self.string = string - generateLines(maxWidth: maxWidth, lineHeightMultiplier: lineHeightMultiplier) + generateLines( + maxWidth: maxWidth, + lineHeightMultiplier: lineHeightMultiplier, + estimatedLineHeight: estimatedLineHeight + ) } // MARK: - Generate lines - - private func generateLines(maxWidth: CGFloat, lineHeightMultiplier: CGFloat) { + + /// Generate line fragments. + /// - Parameters: + /// - maxWidth: The maximum width the line can be. + /// - lineHeightMultiplier: The multiplier to apply to an empty line's height. + /// - estimatedLineHeight: The estimated height of an empty line. + private func generateLines(maxWidth: CGFloat, lineHeightMultiplier: CGFloat, estimatedLineHeight: CGFloat) { guard let typesetter else { return } - var startIndex = 0 - while startIndex < string.length { - let lineBreak = suggestLineBreak( - using: typesetter, - strategy: .word, // TODO: Make this configurable - startingOffset: startIndex, - constrainingWidth: maxWidth - ) - let lineFragment = typesetLine( - range: NSRange(location: startIndex, length: lineBreak - startIndex), + var lines: [TextLineStorage.BuildItem] = [] + var height: CGFloat = 0 + if string.length == 0 { + // Insert an empty fragment + let ctLine = CTTypesetterCreateLine(typesetter, CFRangeMake(0, 0)) + let fragment = LineFragment( + ctLine: ctLine, + width: 0, + height: estimatedLineHeight, + descent: 0, lineHeightMultiplier: lineHeightMultiplier ) - lineFragments.insert( - line: lineFragment, - atIndex: startIndex, - length: lineBreak - startIndex, - height: lineFragment.scaledHeight - ) - startIndex = lineBreak + lines = [.init(data: fragment, length: 0, height: fragment.scaledHeight)] + } else { + var startIndex = 0 + while startIndex < string.length { + let lineBreak = suggestLineBreak( + using: typesetter, + strategy: .word, // TODO: Make this configurable + startingOffset: startIndex, + constrainingWidth: maxWidth + ) + let lineFragment = typesetLine( + range: NSRange(location: startIndex, length: lineBreak - startIndex), + lineHeightMultiplier: lineHeightMultiplier + ) + lines.append(.init(data: lineFragment, length: lineBreak - startIndex, height: lineFragment.scaledHeight)) + startIndex = lineBreak + height = lineFragment.scaledHeight + } } + // Use an efficient tree building algorithm rather than adding lines sequentially + lineFragments.build(from: lines, estimatedLineHeight: height) } - + + /// Typeset a new fragment. + /// - Parameters: + /// - range: The range of the fragment. + /// - lineHeightMultiplier: The multiplier to apply to the line's height. + /// - Returns: A new line fragment. private func typesetLine(range: NSRange, lineHeightMultiplier: CGFloat) -> LineFragment { let ctLine = CTTypesetterCreateLine(typesetter!, CFRangeMake(range.location, range.length)) var ascent: CGFloat = 0 @@ -150,7 +182,10 @@ final class Typesetter { return breakIndex } - + + /// Ensures the character at the given index can break a line. + /// - Parameter index: The index to check at. + /// - Returns: True, if the character is a whitespace or punctuation character. private func ensureCharacterCanBreakLine(at index: Int) -> Bool { let set = CharacterSet( charactersIn: string.attributedSubstring(from: NSRange(location: index, length: 1)).string diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Iterator.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Iterator.swift index 14e85a2eb..99eb26eb7 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Iterator.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Iterator.swift @@ -32,7 +32,8 @@ public extension TextLineStorage { public mutating func next() -> TextLinePosition? { if let currentPosition { guard currentPosition.yPos < maxY, - let nextPosition = storage.getLine(atIndex: currentPosition.range.max) else { + let nextPosition = storage.getLine(atIndex: currentPosition.range.max), + nextPosition.index != currentPosition.index else { return nil } self.currentPosition = nextPosition diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift index b42b78dc0..e913208f4 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift @@ -17,22 +17,17 @@ extension TextLineStorage where Data == TextLine { var index = 0 var lines: [BuildItem] = [] while let range = textStorage.getNextLine(startingAt: index) { - lines.append( - BuildItem( - data: TextLine(), - length: range.max - index - ) - ) + lines.append(BuildItem(data: TextLine(), length: range.max - index, height: estimatedLineHeight)) index = NSMaxRange(range) } // Create the last line if textStorage.length - index > 0 { - lines.append( - BuildItem( - data: TextLine(), - length: textStorage.length - index - ) - ) + lines.append(BuildItem(data: TextLine(), length: textStorage.length - index,height: estimatedLineHeight)) + } + + if textStorage.length == 0 + || LineEnding(rawValue: textStorage.mutableString.substring(from: textStorage.length - 1)) != nil { + lines.append(BuildItem(data: TextLine(), length: 0, height: estimatedLineHeight)) } // Use an efficient tree building algorithm rather than adding lines sequentially diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift index b3e332829..b05622dbf 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift @@ -34,7 +34,7 @@ extension TextLineStorage { /// - Parameters: /// - nodeU: The node to replace. /// - nodeV: The node to insert in place of `nodeU` - internal func transplant(_ nodeU: Node, with nodeV: Node?) { + internal func transplant(_ nodeU: borrowing Node, with nodeV: Node?) { if nodeU.parent == nil { root = nodeV } else if isLeftChild(nodeU) { diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Structs.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Structs.swift index 27bc2c2d4..65dd30e6a 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Structs.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Structs.swift @@ -69,5 +69,6 @@ extension TextLineStorage where Data: Identifiable { public struct BuildItem { public let data: Data public let length: Int + public let height: CGFloat? } } diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift index f9f8f61cd..621e80f41 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift @@ -16,8 +16,7 @@ import Foundation // calls to each node/parent as we do a little walk up the tree. // // Using Unmanaged references resulted in a -15% decrease (0.667s -> 0.563s) in the -// TextLayoutLineStorageTests.test_insertPerformance benchmark when initially tested, and similar optimizations -// have also since been implemented in `insert`. +// TextLayoutLineStorageTests.test_insertPerformance benchmark when first changed to use Unmanaged. // // See: // - https://github.com/apple/swift/blob/main/docs/OptimizationTips.rst#unsafe-code @@ -51,10 +50,15 @@ public final class TextLineStorage { } public var last: TextLinePosition? { - guard length > 0, let position = search(for: length - 1) else { return nil } + guard count > 0, let position = search(forIndex: count - 1) else { return nil } return TextLinePosition(position: position) } + private var lastNode: NodePosition? { + guard count > 0, let position = search(forIndex: count - 1) else { return nil } + return position + } + public init() { } // MARK: - Public Methods @@ -63,8 +67,10 @@ public final class TextLineStorage { /// - Complexity: `O(log n)` where `n` is the number of lines in the storage object. /// - 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: Data, atIndex index: Int, length: Int, height: CGFloat) { + /// - index: The offset to insert the line at. + /// - length: The length of the new line. + /// - height: The height of the new line. + public func insert(line: Data, asOffset 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 @@ -130,6 +136,10 @@ public final class TextLineStorage { /// - 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? { + guard posY < height else { + return last + } + var currentNode = root var currentOffset: Int = root?.leftSubtreeOffset ?? 0 var currentYPosition: CGFloat = root?.leftSubtreeHeight ?? 0 @@ -177,9 +187,15 @@ public final class TextLineStorage { /// - delta: The change in length of the document. Negative for deletes, positive for insertions. /// - 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(index >= 0 && index <= self.length, "Invalid index, expected between 0 and \(self.length). Got \(index)") assert(delta != 0 || deltaHeight != 0, "Delta must be non-0") - guard let position = search(for: index) else { + let position: NodePosition? + if index == self.length { // Updates at the end of the document are valid + position = lastNode + } else { + position = search(for: index) + } + guard let position else { assertionFailure("No line found at index \(index)") return } @@ -202,7 +218,7 @@ public final class TextLineStorage { /// 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)") + assert(index >= 0 && index <= self.length, "Invalid index, expected between 0 and \(self.length). Got \(index)") guard count > 1 else { removeAll() return @@ -225,8 +241,10 @@ public final class TextLineStorage { } /// Efficiently builds the tree from the given array of lines. + /// - Note: Calls ``TextLineStorage/removeAll()`` before building. /// - Parameter lines: The lines to use to build the tree. - public func build(from lines: [BuildItem], estimatedLineHeight: CGFloat) { + public func build(from lines: borrowing [BuildItem], estimatedLineHeight: CGFloat) { + removeAll() root = build(lines: lines, estimatedLineHeight: estimatedLineHeight, left: 0, right: lines.count, parent: nil).0 count = lines.count } @@ -240,7 +258,7 @@ public 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: [BuildItem], + lines: borrowing [BuildItem], estimatedLineHeight: CGFloat, left: Int, right: Int, @@ -254,7 +272,7 @@ public final class TextLineStorage { leftSubtreeOffset: 0, leftSubtreeHeight: 0, leftSubtreeCount: 0, - height: estimatedLineHeight, + height: lines[mid].height ?? estimatedLineHeight, color: .black ) node.parent = parent @@ -298,24 +316,24 @@ public final class TextLineStorage { private extension TextLineStorage { // MARK: - Search - /// Searches for the given index. Returns a node and offset if found. - /// - Parameter index: The index to look for in the document. + /// Searches for the given offset. + /// - Parameter offset: The offset 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) -> NodePosition? { + func search(for offset: Int) -> NodePosition? { 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 { + if offset == currentOffset || (offset >= currentOffset && offset < currentOffset + node.length) { return NodePosition(node: node, yPos: currentYPosition, textPos: currentOffset, index: currentIndex) - } else if currentOffset > index { + } else if currentOffset > offset { 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 { + } else if node.leftSubtreeOffset < offset { currentNode = node.right currentOffset += node.length + (node.right?.leftSubtreeOffset ?? 0) currentYPosition += node.height + (node.right?.leftSubtreeHeight ?? 0) @@ -327,6 +345,32 @@ private extension TextLineStorage { return nil } + /// Searches for the given index. + /// - 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(forIndex index: Int) -> NodePosition? { + 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 == currentIndex { + return NodePosition(node: node, yPos: currentYPosition, textPos: currentOffset, index: currentIndex) + } else if currentIndex > 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 { + currentNode = node.right + currentOffset += node.length + (node.right?.leftSubtreeOffset ?? 0) + currentYPosition += node.height + (node.right?.leftSubtreeHeight ?? 0) + currentIndex += 1 + (node.right?.leftSubtreeCount ?? 0) + } + } + return nil + } + // MARK: - Delete /// A basic RB-Tree node removal with specialization for node metadata. @@ -481,7 +525,7 @@ private extension TextLineStorage { /// Walk up the tree, updating any `leftSubtree` metadata. private func metaFixup( - startingAt node: Node, + startingAt node: borrowing Node, delta: Int, deltaHeight: CGFloat, nodeAction: MetaFixupAction = .none diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift index 46903f9f9..2506b74d8 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift @@ -119,7 +119,7 @@ public extension TextSelectionManager { decomposeCharacters: Bool ) -> NSRange { let range = delta > 0 ? NSRange(location: offset, length: 1) : NSRange(location: offset - 1, length: 1) - if delta > 0 && offset == string.length - 1 { + if delta > 0 && offset == string.length { return NSRange(location: offset, length: 0) } else if delta < 0 && offset == 0 { return NSRange(location: 0, length: 0) diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift index f2b1b9159..f443eac8d 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift @@ -128,19 +128,14 @@ public class TextSelectionManager: NSObject { for textSelection in textSelections { if textSelection.range.isEmpty { - let lineFragment = layoutManager? - .textLineForOffset(textSelection.range.location)? - .data - .lineFragments - .first let cursorOrigin = (layoutManager?.rectForOffset(textSelection.range.location) ?? .zero).origin if textSelection.view == nil || textSelection.boundingRect.origin != cursorOrigin - || textSelection.boundingRect.height != lineFragment?.data.scaledHeight ?? 0 { + || textSelection.boundingRect.height != layoutManager?.estimateLineHeight() ?? 0 { textSelection.view?.removeFromSuperview() let cursorView = CursorView(color: insertionPointColor) cursorView.frame.origin = cursorOrigin - cursorView.frame.size.height = lineFragment?.data.scaledHeight ?? 0 + cursorView.frame.size.height = layoutManager?.estimateLineHeight() ?? 0 layoutView?.addSubview(cursorView) textSelection.view = cursorView textSelection.boundingRect = cursorView.frame diff --git a/Sources/CodeEditInputView/TextView/TextView.swift b/Sources/CodeEditInputView/TextView/TextView.swift index 34ac1a0ff..6200226cd 100644 --- a/Sources/CodeEditInputView/TextView/TextView.swift +++ b/Sources/CodeEditInputView/TextView/TextView.swift @@ -386,7 +386,7 @@ public class TextView: NSView, NSTextContent { public func updateFrameIfNeeded() -> Bool { var availableSize = scrollView?.contentSize ?? .zero availableSize.height -= (scrollView?.contentInsets.top ?? 0) + (scrollView?.contentInsets.bottom ?? 0) - let newHeight = layoutManager.estimatedHeight() + let newHeight = max(layoutManager.estimatedHeight(), availableSize.height) let newWidth = layoutManager.estimatedWidth() var didUpdate = false diff --git a/Sources/CodeEditTextView/Gutter/GutterView.swift b/Sources/CodeEditTextView/Gutter/GutterView.swift index 1dd65120e..e211d8dfd 100644 --- a/Sources/CodeEditTextView/Gutter/GutterView.swift +++ b/Sources/CodeEditTextView/Gutter/GutterView.swift @@ -134,7 +134,7 @@ public class GutterView: NSView { for selection in selectionManager.textSelections where selection.range.isEmpty { guard let line = textView.layoutManager.textLineForOffset(selection.range.location), - visibleRange.intersection(line.range) != nil else { + (visibleRange.intersection(line.range) != nil || selection.range.max == line.range.max) else { continue } context.fill( diff --git a/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift b/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift index 96a7f6e2a..cf82dfef4 100644 --- a/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift +++ b/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift @@ -16,7 +16,7 @@ final class TextLayoutLineStorageTests: XCTestCase { let tree = TextLineStorage() var data = [TextLineStorage.BuildItem]() for idx in 0..<15 { - data.append(.init(data: TextLine(), length: idx + 1)) + data.append(.init(data: TextLine(), length: idx + 1, height: 0.0)) } tree.build(from: data, estimatedLineHeight: 1.0) return tree @@ -223,7 +223,8 @@ final class TextLayoutLineStorageTests: XCTestCase { for idx in 0..<250_000 { lines.append(TextLineStorage.BuildItem( data: TextLine(), - length: idx + 1 + length: idx + 1, + height: 0.0 )) } tree.build(from: lines, estimatedLineHeight: 1.0) @@ -244,7 +245,8 @@ final class TextLayoutLineStorageTests: XCTestCase { let lines: [TextLineStorage.BuildItem] = (0..<250_000).map { TextLineStorage.BuildItem( data: TextLine(), - length: $0 + 1 + length: $0 + 1, + height: 0.0 ) } // Start 0.113s @@ -259,7 +261,8 @@ final class TextLayoutLineStorageTests: XCTestCase { for idx in 0..<100_000 { lines.append(TextLineStorage.BuildItem( data: TextLine(), - length: idx + 1 + length: idx + 1, + height: 0.0 )) } tree.build(from: lines, estimatedLineHeight: 1.0) From f7760afbe3df57fa85c0e9bc290691d35bb81d25 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 19 Oct 2023 18:33:44 -0500 Subject: [PATCH 56/75] Fix Empty File, End Of File, LineEndings --- .../TextLayoutManager+Edits.swift | 147 +----------------- .../TextLayoutManager+Public.swift | 12 +- .../TextLayoutManager/TextLayoutManager.swift | 4 +- .../TextLine/LineFragment.swift | 2 +- .../TextLine/LineFragmentView.swift | 4 +- .../CodeEditInputView/TextLine/TextLine.swift | 8 +- .../TextLine/Typesetter.swift | 14 +- .../TextLineStorage+Iterator.swift | 10 +- .../TextLineStorage+NSTextStorage.swift | 2 +- .../TextLineStorage/TextLineStorage.swift | 23 ++- ...lectionManager+SelectionManipulation.swift | 2 +- .../TextView/TextView+NSTextInput.swift | 2 +- .../CodeEditInputView/Utils/LineEnding.swift | 42 ++--- .../CodeEditTextView/Gutter/GutterView.swift | 2 +- .../LineEndingTests.swift | 92 +++++++++++ .../TextLayoutLineStorageTests.swift | 14 +- 16 files changed, 175 insertions(+), 205 deletions(-) create mode 100644 Tests/CodeEditInputViewTests/LineEndingTests.swift diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift index 6b949a8ac..44b344d46 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift @@ -28,7 +28,7 @@ extension TextLayoutManager: NSTextStorageDelegate { // Delete line lineStorage.delete(lineAt: linePosition.range.location) } else if intersection.max == linePosition.range.max, - let nextLine = lineStorage.getLine(atIndex: linePosition.range.max) { + let nextLine = lineStorage.getLine(atOffset: 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 @@ -40,7 +40,7 @@ extension TextLayoutManager: NSTextStorageDelegate { } } - // Loop through each line being inserted, inserting where necessary + // Loop through each line being inserted, inserting & splitting where necessary if !string.isEmpty { var index = 0 while let nextLine = (string as NSString).getNextLine(startingAt: index) { @@ -72,13 +72,13 @@ extension TextLayoutManager: NSTextStorageDelegate { lineStorage.update(atIndex: location, delta: insertedString.length, deltaHeight: 0.0) lineStorage.insert( line: TextLine(), - asOffset: location + insertedString.length, + atOffset: location + insertedString.length, length: 0, height: estimateLineHeight() ) } else { // Need to split the line inserting into and create a new line with the split section of the line - guard let linePosition = lineStorage.getLine(atIndex: location) else { return } + guard let linePosition = lineStorage.getLine(atOffset: location) else { return } let splitLocation = location + insertedString.length let splitLength = linePosition.range.max - location let lineDelta = insertedString.length - splitLength // The difference in the line being edited @@ -88,7 +88,7 @@ extension TextLayoutManager: NSTextStorageDelegate { lineStorage.insert( line: TextLine(), - asOffset: splitLocation, + atOffset: splitLocation, length: splitLength, height: estimateLineHeight() ) @@ -112,140 +112,3 @@ extension TextLayoutManager: NSTextStorageDelegate { } } } - -extension TextLineStorage { - var description: String { - treeString(root!) { - ("\($0.length)", $0.left, $0.right) - } - } -} - -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.. TextLineStorage.TextLinePosition? { - lineStorage.getLine(atIndex: offset) + if offset == lineStorage.length { + return lineStorage.last + } else { + return lineStorage.getLine(atOffset: offset) + } } /// Finds text line and returns it if found. @@ -75,7 +79,7 @@ extension TextLayoutManager { guard offset != lineStorage.length else { return rectForEndOffset() } - guard let linePosition = lineStorage.getLine(atIndex: offset) else { + guard let linePosition = lineStorage.getLine(atOffset: offset) else { return nil } if linePosition.data.lineFragments.isEmpty { @@ -86,7 +90,7 @@ extension TextLayoutManager { } guard let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine( - atIndex: offset - linePosition.range.location + atOffset: offset - linePosition.range.location ) else { return nil } @@ -139,7 +143,7 @@ extension TextLayoutManager { /// Forces layout calculation for all lines up to and including the given offset. /// - Parameter offset: The offset to ensure layout until. public func ensureLayoutUntil(_ offset: Int) { - guard let linePosition = lineStorage.getLine(atIndex: offset), + guard let linePosition = lineStorage.getLine(atOffset: offset), let visibleRect = delegate?.visibleRect, visibleRect.maxY < linePosition.yPos + linePosition.height, let startingLinePosition = lineStorage.getLine(atPosition: visibleRect.minY) diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift index 2d871fa13..a1cbd1648 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift @@ -115,7 +115,7 @@ public class TextLayoutManager: NSObject { let nanos = elapsed * UInt64(info.numer) / UInt64(info.denom) print("Text Layout Manager built in: ", TimeInterval(nanos) / TimeInterval(NSEC_PER_MSEC), "ms") } - + /// Estimates the line height for the current typing attributes. /// Takes into account ``TextLayoutManager/lineHeightMultiplier``. /// - Returns: The estimated line height. @@ -269,7 +269,7 @@ public class TextLayoutManager: NSObject { let line = position.data line.prepareForDisplay( maxWidth: maxWidth, - lineHeightMultiplier: lineHeightMultiplier, + lineHeightMultiplier: lineHeightMultiplier, estimatedLineHeight: estimateLineHeight(), range: position.range, stringRef: textStorage diff --git a/Sources/CodeEditInputView/TextLine/LineFragment.swift b/Sources/CodeEditInputView/TextLine/LineFragment.swift index 81776892c..e8905b6ee 100644 --- a/Sources/CodeEditInputView/TextLine/LineFragment.swift +++ b/Sources/CodeEditInputView/TextLine/LineFragment.swift @@ -16,7 +16,7 @@ public final class LineFragment: Identifiable, Equatable { public let height: CGFloat public let descent: CGFloat public let scaledHeight: CGFloat - + /// The difference between the real text height and the scaled height public var heightDifference: CGFloat { scaledHeight - height diff --git a/Sources/CodeEditInputView/TextLine/LineFragmentView.swift b/Sources/CodeEditInputView/TextLine/LineFragmentView.swift index d5ba4af0d..d6e5fd7c3 100644 --- a/Sources/CodeEditInputView/TextLine/LineFragmentView.swift +++ b/Sources/CodeEditInputView/TextLine/LineFragmentView.swift @@ -15,13 +15,13 @@ final class LineFragmentView: NSView { override var isFlipped: Bool { true } - + /// Prepare the view for reuse, clears the line fragment reference. override func prepareForReuse() { super.prepareForReuse() lineFragment = nil } - + /// Set a new line fragment for this view, updating view size. /// - Parameter newFragment: The new fragment to use. public func setLineFragment(_ newFragment: LineFragment) { diff --git a/Sources/CodeEditInputView/TextLine/TextLine.swift b/Sources/CodeEditInputView/TextLine/TextLine.swift index 42387b968..b20c18744 100644 --- a/Sources/CodeEditInputView/TextLine/TextLine.swift +++ b/Sources/CodeEditInputView/TextLine/TextLine.swift @@ -14,18 +14,18 @@ public final class TextLine: Identifiable, Equatable { private var needsLayout: Bool = true var maxWidth: CGFloat? private(set) var typesetter: Typesetter = Typesetter() - + /// The line fragments contained by this text line. public var lineFragments: TextLineStorage { typesetter.lineFragments } - + /// Marks this line as needing layout and clears all typesetting data. public func setNeedsLayout() { needsLayout = true typesetter = Typesetter() } - + /// Determines if the line needs to be laid out again. /// - Parameter maxWidth: The new max width to check. /// - Returns: True, if this line has been marked as needing layout using ``TextLine/setNeedsLayout()`` or if the @@ -33,7 +33,7 @@ public final class TextLine: Identifiable, Equatable { func needsLayout(maxWidth: CGFloat) -> Bool { needsLayout || maxWidth != self.maxWidth } - + /// Prepares the line for display, generating all potential line breaks and calculating the real height of the line. /// - Parameters: /// - maxWidth: The maximum width the line can be. Used to find line breaks. diff --git a/Sources/CodeEditInputView/TextLine/Typesetter.swift b/Sources/CodeEditInputView/TextLine/Typesetter.swift index 47d94d467..bb8d76232 100644 --- a/Sources/CodeEditInputView/TextLine/Typesetter.swift +++ b/Sources/CodeEditInputView/TextLine/Typesetter.swift @@ -34,7 +34,7 @@ final class Typesetter { } // MARK: - Generate lines - + /// Generate line fragments. /// - Parameters: /// - maxWidth: The maximum width the line can be. @@ -50,7 +50,7 @@ final class Typesetter { let fragment = LineFragment( ctLine: ctLine, width: 0, - height: estimatedLineHeight, + height: estimatedLineHeight/lineHeightMultiplier, descent: 0, lineHeightMultiplier: lineHeightMultiplier ) @@ -68,7 +68,11 @@ final class Typesetter { range: NSRange(location: startIndex, length: lineBreak - startIndex), lineHeightMultiplier: lineHeightMultiplier ) - lines.append(.init(data: lineFragment, length: lineBreak - startIndex, height: lineFragment.scaledHeight)) + lines.append(.init( + data: lineFragment, + length: lineBreak - startIndex, + height: lineFragment.scaledHeight + )) startIndex = lineBreak height = lineFragment.scaledHeight } @@ -76,7 +80,7 @@ final class Typesetter { // Use an efficient tree building algorithm rather than adding lines sequentially lineFragments.build(from: lines, estimatedLineHeight: height) } - + /// Typeset a new fragment. /// - Parameters: /// - range: The range of the fragment. @@ -182,7 +186,7 @@ final class Typesetter { return breakIndex } - + /// Ensures the character at the given index can break a line. /// - Parameter index: The index to check at. /// - Returns: True, if the character is a whitespace or punctuation character. diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Iterator.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Iterator.swift index 99eb26eb7..34377bd4b 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Iterator.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Iterator.swift @@ -32,7 +32,7 @@ public extension TextLineStorage { public mutating func next() -> TextLinePosition? { if let currentPosition { guard currentPosition.yPos < maxY, - let nextPosition = storage.getLine(atIndex: currentPosition.range.max), + let nextPosition = storage.getLine(atOffset: currentPosition.range.max), nextPosition.index != currentPosition.index else { return nil } @@ -61,12 +61,12 @@ public extension TextLineStorage { public mutating func next() -> TextLinePosition? { if let currentPosition { guard currentPosition.range.max < range.max, - let nextPosition = storage.getLine(atIndex: currentPosition.range.max) else { + let nextPosition = storage.getLine(atOffset: currentPosition.range.max) else { return nil } self.currentPosition = nextPosition return self.currentPosition! - } else if let nextPosition = storage.getLine(atIndex: range.location) { + } else if let nextPosition = storage.getLine(atOffset: range.location) { self.currentPosition = nextPosition return nextPosition } else { @@ -93,12 +93,12 @@ extension TextLineStorage: LazySequenceProtocol { public mutating func next() -> TextLinePosition? { if let currentPosition { guard currentPosition.range.max < storage.length, - let nextPosition = storage.getLine(atIndex: currentPosition.range.max) else { + let nextPosition = storage.getLine(atOffset: currentPosition.range.max) else { return nil } self.currentPosition = nextPosition return self.currentPosition! - } else if let nextPosition = storage.getLine(atIndex: 0) { + } else if let nextPosition = storage.getLine(atOffset: 0) { self.currentPosition = nextPosition return nextPosition } else { diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift index e913208f4..b5e6d10d4 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift @@ -22,7 +22,7 @@ extension TextLineStorage where Data == TextLine { } // Create the last line if textStorage.length - index > 0 { - lines.append(BuildItem(data: TextLine(), length: textStorage.length - index,height: estimatedLineHeight)) + lines.append(BuildItem(data: TextLine(), length: textStorage.length - index, height: estimatedLineHeight)) } if textStorage.length == 0 diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift index 621e80f41..cd4ded0fc 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift @@ -42,10 +42,7 @@ public final class TextLineStorage { public var height: CGFloat = 0 public var first: TextLinePosition? { - guard length > 0, - let position = search(for: 0) else { - return nil - } + guard count > 0, let position = search(forIndex: 0) else { return nil } return TextLinePosition(position: position) } @@ -70,7 +67,7 @@ public final class TextLineStorage { /// - index: The offset to insert the line at. /// - length: The length of the new line. /// - height: The height of the new line. - public func insert(line: Data, asOffset index: Int, length: Int, height: CGFloat) { + public func insert(line: Data, atOffset 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 @@ -120,13 +117,23 @@ public final class TextLineStorage { insertFixup(node: insertedNode) } + /// Fetches a line for the given offset. + /// + /// - Complexity: `O(log n)` + /// - Parameter offset: The offset to fetch for. + /// - Returns:A ``TextLineStorage/TextLinePosition`` struct with relevant position and line information. + public func getLine(atOffset offset: Int) -> TextLinePosition? { + guard let nodePosition = search(for: offset) else { return nil } + return TextLinePosition(position: nodePosition) + } + /// 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. + /// - Returns: A ``TextLineStorage/TextLinePosition`` struct with relevant position and line information. public func getLine(atIndex index: Int) -> TextLinePosition? { - guard let nodePosition = search(for: index) else { return nil } + guard let nodePosition = search(forIndex: index) else { return nil } return TextLinePosition(position: nodePosition) } @@ -134,7 +141,7 @@ public final class TextLineStorage { /// /// - 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. + /// - Returns: A ``TextLineStorage/TextLinePosition`` struct with relevant position and line information. public func getLine(atPosition posY: CGFloat) -> TextLinePosition? { guard posY < height else { return last diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift index 2506b74d8..32b5dec68 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift @@ -198,7 +198,7 @@ public extension TextSelectionManager { /// - Returns: The range of the extended selection. private func extendSelectionVisualLine(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) + let lineFragment = line.data.typesetter.lineFragments.getLine(atOffset: offset - line.range.location) else { return NSRange(location: offset, length: 0) } diff --git a/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift b/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift index 5f0875d10..658221019 100644 --- a/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift +++ b/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift @@ -235,7 +235,7 @@ extension TextView: NSTextInputClient { // 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 + atOffset: anIndex - linePosition.range.location ) else { return 0 } diff --git a/Sources/CodeEditInputView/Utils/LineEnding.swift b/Sources/CodeEditInputView/Utils/LineEnding.swift index bb90889ee..2e5051d22 100644 --- a/Sources/CodeEditInputView/Utils/LineEnding.swift +++ b/Sources/CodeEditInputView/Utils/LineEnding.swift @@ -7,7 +7,7 @@ import AppKit -public enum LineEnding: String { +public enum LineEnding: String, CaseIterable { /// The default unix `\n` character case lineFeed = "\n" /// MacOS line ending `\r` character @@ -18,19 +18,9 @@ public enum LineEnding: String { /// Initialize a line ending from a line string. /// - Parameter line: The line to use public init?(line: String) { - var iterator = line.lazy.reversed().makeIterator() - guard let endChar = iterator.next() else { return nil } - if endChar == "\n" { - if let nextEndChar = iterator.next(), nextEndChar == "\r" { - self = .carriageReturnLineFeed - } else { - self = .lineFeed - } - } else if endChar == "\r" { - self = .carriageReturn - } else { - return nil - } + guard let lastChar = line.last, + let lineEnding = LineEnding(rawValue: String(lastChar)) else { return nil } + self = lineEnding } /// Attempts to detect the line ending from a line storage. @@ -40,11 +30,9 @@ public enum LineEnding: String { lineStorage: TextLineStorage, textStorage: NSTextStorage ) -> LineEnding { - var histogram: [LineEnding: Int] = [ - .lineFeed: 0, - .carriageReturn: 0, - .carriageReturnLineFeed: 0 - ] + var histogram: [LineEnding: Int] = LineEnding.allCases.reduce(into: [LineEnding: Int]()) { + $0[$1] = 0 + } var shouldContinue = true var lineIterator = lineStorage.makeIterator() @@ -60,10 +48,22 @@ public enum LineEnding: String { } } - return histogram.max(by: { $0.value < $1.value })?.key ?? .lineFeed + let orderedValues = histogram.sorted(by: { $0.value > $1.value }) + // Return the max of the histogram, but if there's no max + // we default to lineFeed. This should be a parameter in the future. + if orderedValues.count >= 2 { + if orderedValues[0].value == orderedValues[1].value { + return .lineFeed + } else { + return orderedValues[0].key + } + } else { + return .lineFeed + } } + /// The UTF-16 Length of the line ending. public var length: Int { - rawValue.count + rawValue.utf16.count } } diff --git a/Sources/CodeEditTextView/Gutter/GutterView.swift b/Sources/CodeEditTextView/Gutter/GutterView.swift index e211d8dfd..318074cec 100644 --- a/Sources/CodeEditTextView/Gutter/GutterView.swift +++ b/Sources/CodeEditTextView/Gutter/GutterView.swift @@ -134,7 +134,7 @@ public class GutterView: NSView { for selection in selectionManager.textSelections where selection.range.isEmpty { guard let line = textView.layoutManager.textLineForOffset(selection.range.location), - (visibleRange.intersection(line.range) != nil || selection.range.max == line.range.max) else { + (visibleRange.intersection(line.range) != nil || selection.range.location == textView.length) else { continue } context.fill( diff --git a/Tests/CodeEditInputViewTests/LineEndingTests.swift b/Tests/CodeEditInputViewTests/LineEndingTests.swift new file mode 100644 index 000000000..211f5c843 --- /dev/null +++ b/Tests/CodeEditInputViewTests/LineEndingTests.swift @@ -0,0 +1,92 @@ +import XCTest +@testable import CodeEditInputView + +// swiftlint:disable all + +class LineEndingTests: XCTestCase { + func test_lineEndingCreateUnix() { + // The \n character + XCTAssertTrue(LineEnding(rawValue: "\n") != nil, "Line ending failed to initialize with the \\n character") + + let line = "Loren Ipsum\n" + XCTAssertTrue(LineEnding(line: line) != nil, "Line ending failed to initialize with a line ending in \\n") + } + + func test_lineEndingCreateCRLF() { + // The \r\n sequence + XCTAssertTrue(LineEnding(rawValue: "\r\n") != nil, "Line ending failed to initialize with the \\r\\n sequence") + + let line = "Loren Ipsum\r\n" + XCTAssertTrue(LineEnding(line: line) != nil, "Line ending failed to initialize with a line ending in \\r\\n") + } + + func test_lineEndingCreateMacOS() { + // The \r character + XCTAssertTrue(LineEnding(rawValue: "\r") != nil, "Line ending failed to initialize with the \\r character") + + let line = "Loren Ipsum\r" + XCTAssertTrue(LineEnding(line: line) != nil, "Line ending failed to initialize with a line ending in \\r") + } + + func test_detectLineEndingDefault() { + // There was a bug in this that caused it to flake sometimes, so we run this a couple times to ensure it's not flaky. + // The odds of it being bad with the earlier bug after running 20 times is incredibly small + for _ in 0..<20 { + let storage = NSTextStorage(string: "hello world") // No line ending + let lineStorage = TextLineStorage() + lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10) + let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage) + XCTAssertTrue(detected == .lineFeed, "Default detected line ending incorrect, expected: \n, got: \(detected.rawValue.debugDescription)") + } + } + + func test_detectLineEndingUnix() { + let corpus = "abcdefghijklmnopqrstuvwxyz123456789" + let goalLineEnding = LineEnding.lineFeed + + let text = (10..() + lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10) + + let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage) + XCTAssertTrue(detected == goalLineEnding, "Incorrect detected line ending, expected: \(goalLineEnding.rawValue.debugDescription), got \(detected.rawValue.debugDescription)") + } + + func test_detectLineEndingCLRF() { + let corpus = "abcdefghijklmnopqrstuvwxyz123456789" + let goalLineEnding = LineEnding.carriageReturnLineFeed + + let text = (10..() + lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10) + + let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage) + XCTAssertTrue(detected == goalLineEnding, "Incorrect detected line ending, expected: \(goalLineEnding.rawValue.debugDescription), got \(detected.rawValue.debugDescription)") + } + + func test_detectLineEndingMacOS() { + let corpus = "abcdefghijklmnopqrstuvwxyz123456789" + let goalLineEnding = LineEnding.carriageReturn + + let text = (10..() + lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10) + + let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage) + XCTAssertTrue(detected == goalLineEnding, "Incorrect detected line ending, expected: \(goalLineEnding.rawValue.debugDescription), got \(detected.rawValue.debugDescription)") + } +} + +// swiftlint:enable all diff --git a/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift b/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift index cf82dfef4..db8d4aa2b 100644 --- a/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift +++ b/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift @@ -64,7 +64,7 @@ final class TextLayoutLineStorageTests: XCTestCase { var tree = TextLineStorage() // Single Element - tree.insert(line: TextLine(), atIndex: 0, length: 1, height: 50.0) + tree.insert(line: TextLine(), atOffset: 0, length: 1, height: 50.0) XCTAssert(tree.length == 1, "Tree length incorrect") XCTAssert(tree.count == 1, "Tree count incorrect") XCTAssert(tree.height == 50.0, "Tree height incorrect") @@ -73,16 +73,16 @@ final class TextLayoutLineStorageTests: XCTestCase { // Insert into first tree = createBalancedTree() - tree.insert(line: TextLine(), atIndex: 0, length: 1, height: 1.0) + tree.insert(line: TextLine(), atOffset: 0, length: 1, height: 1.0) try assertTreeMetadataCorrect(tree) // Insert into last tree = createBalancedTree() - tree.insert(line: TextLine(), atIndex: tree.length - 1, length: 1, height: 1.0) + tree.insert(line: TextLine(), atOffset: tree.length - 1, length: 1, height: 1.0) try assertTreeMetadataCorrect(tree) tree = createBalancedTree() - tree.insert(line: TextLine(), atIndex: 45, length: 1, height: 1.0) + tree.insert(line: TextLine(), atOffset: 45, length: 1, height: 1.0) try assertTreeMetadataCorrect(tree) } @@ -90,7 +90,7 @@ final class TextLayoutLineStorageTests: XCTestCase { var tree = TextLineStorage() // Single Element - tree.insert(line: TextLine(), atIndex: 0, length: 1, height: 1.0) + tree.insert(line: TextLine(), atOffset: 0, length: 1, height: 1.0) tree.update(atIndex: 0, delta: 20, deltaHeight: 5.0) XCTAssert(tree.length == 21, "Tree length incorrect") XCTAssert(tree.count == 1, "Tree count incorrect") @@ -145,7 +145,7 @@ final class TextLayoutLineStorageTests: XCTestCase { var tree = TextLineStorage() // Single Element - tree.insert(line: TextLine(), atIndex: 0, length: 1, height: 1.0) + tree.insert(line: TextLine(), atOffset: 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") @@ -234,7 +234,7 @@ final class TextLayoutLineStorageTests: XCTestCase { measure { for _ in 0..<100_000 { tree.insert( - line: TextLine(), atIndex: Int.random(in: 0.. Date: Fri, 20 Oct 2023 13:19:51 -0500 Subject: [PATCH 57/75] Fix TextFormation, EOD Selection, Layout Transactions --- .../TextLayoutManager+Public.swift | 15 +++++-- .../TextLayoutManager/TextLayoutManager.swift | 43 ++++++++++++++----- ...lectionManager+SelectionManipulation.swift | 2 +- .../TextView/TextView+Accessibility.swift | 7 +++ .../TextView/TextView+NSTextInput.swift | 36 ++++++++-------- .../TextView+TextLayoutManagerDelegate.swift | 7 +-- .../CodeEditInputView/TextView/TextView.swift | 25 ++++++----- .../TextViewController+LoadView.swift | 1 + .../TextView+/TextView+TextFormation.swift | 1 + 9 files changed, 84 insertions(+), 53 deletions(-) diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift index 0c901f2f1..a96f2003a 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift @@ -53,11 +53,18 @@ extension TextLayoutManager { return position.range.location + fragmentPosition.range.location } else if fragment.width < point.x - edgeInsets.left { let fragmentRange = CTLineGetStringRange(fragment.ctLine) + let globalFragmentRange = NSRange( + location: position.range.location + fragmentRange.location, + length: fragmentRange.length + ) + let endPosition = position.range.location + fragmentRange.location + fragmentRange.length // Return eol - return position.range.location + fragmentRange.location + fragmentRange.length - ( + return endPosition - ( // Before the eol character (insertion point is before the eol) - fragmentPosition.range.max == position.range.max ? - 1 : detectedLineEnding.length + // And the line *has* an eol character + fragmentPosition.range.max == position.range.max + && LineEnding(line: textStorage.substring(from: globalFragmentRange) ?? "") != nil + ? detectedLineEnding.length : 0 ) } else { // Somewhere in the fragment @@ -177,7 +184,7 @@ extension TextLayoutManager { /// - Parameter offset: The offset to ensure layout until. private func ensureLayoutFor(position: TextLineStorage.TextLinePosition) -> CGFloat { position.data.prepareForDisplay( - maxWidth: maxLineWidth, + maxWidth: maxLineLayoutWidth, lineHeightMultiplier: lineHeightMultiplier, estimatedLineHeight: estimateLineHeight(), range: position.range, diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift index a1cbd1648..472f2ab60 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift @@ -12,13 +12,13 @@ import Common public protocol TextLayoutManagerDelegate: AnyObject { func layoutManagerHeightDidUpdate(newHeight: CGFloat) func layoutManagerMaxWidthDidChange(newWidth: CGFloat) - func textViewSize() -> CGSize - func textLayoutSetNeedsDisplay() + func textViewportSize() -> CGSize func layoutManagerYAdjustment(_ yAdjustment: CGFloat) var visibleRect: NSRect { get } } +/// The text layout manager manages laying out lines in a code document. public class TextLayoutManager: NSObject { // MARK: - Public Properties @@ -60,17 +60,25 @@ public class TextLayoutManager: NSObject { private var visibleLineIds: Set = [] /// Used to force a complete re-layout using `setNeedsLayout` private var needsLayout: Bool = false - private(set) public var isInTransaction: Bool = false + + private var transactionCounter: Int = 0 + public var isInTransaction: Bool { + transactionCounter > 0 + } weak internal var layoutView: NSView? + /// The calculated maximum width of all laid out lines. + /// - Note: This does not indicate *the* maximum width of the text view if all lines have not been laid out. + /// This will be updated if it comes across a wider line. internal var maxLineWidth: CGFloat = 0 { didSet { delegate?.layoutManagerMaxWidthDidChange(newWidth: maxLineWidth + edgeInsets.horizontal) } } - private var maxLineLayoutWidth: CGFloat { - wrapLines ? (delegate?.textViewSize().width ?? .greatestFiniteMagnitude) - edgeInsets.horizontal + /// The maximum width available to lay out lines in. + internal var maxLineLayoutWidth: CGFloat { + wrapLines ? (delegate?.textViewportSize().width ?? .greatestFiniteMagnitude) - edgeInsets.horizontal : .greatestFiniteMagnitude } @@ -103,17 +111,21 @@ public 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, 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 } /// Estimates the line height for the current typing attributes. @@ -167,15 +179,27 @@ public class TextLayoutManager: NSObject { /// 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. + /// + /// You can nest transaction start/end calls, the layout manager will not cause layout until the last transaction + /// group is ended. + /// + /// Ensure there is a balanced number of begin/end calls. If there is a missing endTranscaction call, the layout + /// manager will never lay out text. If there is a end call without matching a start call an assertionFailure + /// will occur. public func beginTransaction() { - isInTransaction = true + transactionCounter += 1 } /// Ends a transaction. When called, the layout manager will layout any necessary lines. public func endTransaction() { - isInTransaction = false - setNeedsLayout() - layoutLines() + transactionCounter -= 1 + if transactionCounter == 0 { + setNeedsLayout() + layoutLines() + } else if transactionCounter < 0 { + // swiftlint:disable:next line_length + assertionFailure("TextLayoutManager.endTransaction called without a matching TextLayoutManager.beginTransaction call") + } } // MARK: - Layout @@ -196,7 +220,6 @@ public class TextLayoutManager: NSObject { for linePosition in lineStorage.linesStartingAt(minY, until: maxY) { // Updating height in the loop may cause the iterator to be wrong guard linePosition.yPos < maxY else { break } - if forceLayout || linePosition.data.needsLayout(maxWidth: maxLineLayoutWidth) || !visibleLineIds.contains(linePosition.data.id) { diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift index 32b5dec68..c30b3c727 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift @@ -304,7 +304,7 @@ public extension TextSelectionManager { if up { return NSRange(location: 0, length: offset) } else { - return NSRange(location: offset, length: (textStorage?.length ?? 0) - offset - 1) + return NSRange(location: offset, length: (textStorage?.length ?? 0) - offset) } } } diff --git a/Sources/CodeEditInputView/TextView/TextView+Accessibility.swift b/Sources/CodeEditInputView/TextView/TextView+Accessibility.swift index df94e05fa..aa4fe3153 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Accessibility.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Accessibility.swift @@ -7,6 +7,13 @@ import AppKit +/// # Notes +/// +/// This implementation considers the entire document as one element, ignoring all subviews and lines. +/// Another idea would be to make each line fragment an accessibility element, with options for navigating through +/// lines from there. The text view would then only handle text input, and lines would handle reading out useful data +/// to the user. +/// More research needs to be done for the best option here. extension TextView { override open func isAccessibilityElement() -> Bool { true diff --git a/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift b/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift index 658221019..5c4ef131b 100644 --- a/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift +++ b/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift @@ -7,25 +7,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 with multiple cursors,* - **use Xcode as an example of this behavior.* - */ - +/// # Notes for Marked Text +/// +/// 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 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 diff --git a/Sources/CodeEditInputView/TextView/TextView+TextLayoutManagerDelegate.swift b/Sources/CodeEditInputView/TextView/TextView+TextLayoutManagerDelegate.swift index 3b15bbc23..4a1ddd738 100644 --- a/Sources/CodeEditInputView/TextView/TextView+TextLayoutManagerDelegate.swift +++ b/Sources/CodeEditInputView/TextView/TextView+TextLayoutManagerDelegate.swift @@ -16,7 +16,7 @@ extension TextView: TextLayoutManagerDelegate { updateFrameIfNeeded() } - public func textViewSize() -> CGSize { + public func textViewportSize() -> CGSize { if let scrollView = scrollView { var size = scrollView.contentSize size.height -= scrollView.contentInsets.top + scrollView.contentInsets.bottom @@ -26,11 +26,6 @@ extension TextView: TextLayoutManagerDelegate { } } - public func textLayoutSetNeedsDisplay() { - needsDisplay = true - needsLayout = true - } - public func layoutManagerYAdjustment(_ yAdjustment: CGFloat) { var point = scrollView?.documentVisibleRect.origin ?? .zero point.y += yAdjustment diff --git a/Sources/CodeEditInputView/TextView/TextView.swift b/Sources/CodeEditInputView/TextView/TextView.swift index 6200226cd..195d1860d 100644 --- a/Sources/CodeEditInputView/TextView/TextView.swift +++ b/Sources/CodeEditInputView/TextView/TextView.swift @@ -13,19 +13,18 @@ import TextStory // to extensions without a lot of work. // swiftlint:disable type_body_length -/** - ``` - TextView - |-> 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, stored in a line storage for long lines - | |-> [LineFragmentView] Reusable line fragment view that draws a line fragment. - | - |-> TextSelectionManager Maintains, modifies, and renders text selections - | |-> [TextSelection] - ``` - */ +/// +/// ``` +/// TextView +/// |-> 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, stored in a line storage for long lines +/// | |-> [LineFragmentView] Reusable line fragment view that draws a line fragment. +/// | +/// |-> TextSelectionManager Maintains, modifies, and renders text selections +/// | |-> [TextSelection] +/// ``` public class TextView: NSView, NSTextContent { // MARK: - Statics diff --git a/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift b/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift index 1503ce332..6fef69b15 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift @@ -53,6 +53,7 @@ extension TextViewController { self.view = scrollView setUpHighlighter() + setUpTextFormation() NSLayoutConstraint.activate([ scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), diff --git a/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift b/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift index 3844ab86d..92a5b6ab6 100644 --- a/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift +++ b/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift @@ -35,6 +35,7 @@ extension TextView: TextInterface { /// Applies the mutation to the text view. /// - Parameter mutation: The mutation to apply. public func applyMutation(_ mutation: TextMutation) { + layoutManager.willReplaceCharactersInRange(range: mutation.range, with: mutation.string) textStorage.replaceCharacters(in: mutation.range, with: mutation.string) } } From 5bf20edf6ac3c6ac84bc73bdc6e87ad84982f8b3 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 21 Oct 2023 01:56:47 -0500 Subject: [PATCH 58/75] Fix textOffsetAtPoint, Add Selection getFillRects --- .../TextLayoutManager+Public.swift | 17 +- .../TextLine/LineFragmentView.swift | 7 + .../TextSelectionManager.swift | 164 ++++++++++++------ .../TextView/TextView+Drag.swift | 89 +++++++--- .../TextView/TextView+Mouse.swift | 81 +++++++++ .../TextView/TextView+Select.swift | 28 +++ .../CodeEditInputView/TextView/TextView.swift | 34 +--- .../Extensions/NSFont+RulerFont.swift | 2 +- 8 files changed, 310 insertions(+), 112 deletions(-) create mode 100644 Sources/CodeEditInputView/TextView/TextView+Mouse.swift create mode 100644 Sources/CodeEditInputView/TextView/TextView+Select.swift diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift index a96f2003a..791f04ee1 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift @@ -58,14 +58,15 @@ extension TextLayoutManager { length: fragmentRange.length ) let endPosition = position.range.location + fragmentRange.location + fragmentRange.length - // Return eol - return endPosition - ( - // Before the eol character (insertion point is before the eol) - // And the line *has* an eol character - fragmentPosition.range.max == position.range.max - && LineEnding(line: textStorage.substring(from: globalFragmentRange) ?? "") != nil - ? detectedLineEnding.length : 0 - ) + + // If the endPosition is at the end of the line, and the line ends with a line ending character + // return the index before the eol. + if endPosition == position.range.max, + let lineEnding = LineEnding(line: textStorage.substring(from: globalFragmentRange) ?? "") { + return endPosition - lineEnding.length + } else { + return endPosition + } } else { // Somewhere in the fragment let fragmentIndex = CTLineGetStringIndexForPosition( diff --git a/Sources/CodeEditInputView/TextLine/LineFragmentView.swift b/Sources/CodeEditInputView/TextLine/LineFragmentView.swift index d6e5fd7c3..1f5bff496 100644 --- a/Sources/CodeEditInputView/TextLine/LineFragmentView.swift +++ b/Sources/CodeEditInputView/TextLine/LineFragmentView.swift @@ -16,10 +16,15 @@ final class LineFragmentView: NSView { true } + override var isOpaque: Bool { + false + } + /// Prepare the view for reuse, clears the line fragment reference. override func prepareForReuse() { super.prepareForReuse() lineFragment = nil + } /// Set a new line fragment for this view, updating view size. @@ -35,6 +40,8 @@ final class LineFragmentView: NSView { return } context.saveGState() + context.setAllowsFontSmoothing(true) + context.setShouldSmoothFonts(true) context.textMatrix = .init(scaleX: 1, y: -1) context.textPosition = CGPoint( x: 0, diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift index f443eac8d..d66eef086 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift @@ -9,7 +9,7 @@ import AppKit import Common public protocol TextSelectionManagerDelegate: AnyObject { - var font: NSFont { get } + var visibleTextRange: NSRange? { get } func setNeedsDisplay() func estimatedLineHeight() -> CGFloat @@ -27,7 +27,7 @@ public class TextSelectionManager: NSObject { // MARK: - TextSelection - public class TextSelection { + public class TextSelection: Hashable { public var range: NSRange internal weak var view: CursorView? internal var boundingRect: CGRect = .zero @@ -43,6 +43,14 @@ public class TextSelectionManager: NSObject { var isCursor: Bool { range.length == 0 } + + public func hash(into hasher: inout Hasher) { + hasher.combine(range) + } + + public static func == (lhs: TextSelection, rhs: TextSelection) -> Bool { + lhs.range == rhs.range + } } public enum Destination { @@ -78,7 +86,7 @@ public class TextSelectionManager: NSObject { public var selectionBackgroundColor: NSColor = NSColor.selectedTextBackgroundColor private var markedText: [MarkedText] = [] - private(set) public var textSelections: [TextSelection] = [] + private(set) public var textSelections: Set = [] internal weak var layoutManager: TextLayoutManager? internal weak var textStorage: NSTextStorage? internal weak var layoutView: NSView? @@ -112,11 +120,19 @@ public class TextSelectionManager: NSObject { public func setSelectedRanges(_ ranges: [NSRange]) { textSelections.forEach { $0.view?.removeFromSuperview() } - textSelections = ranges.map { + textSelections = Set(ranges.map { let selection = TextSelection(range: $0) selection.suggestedXPos = layoutManager?.rectForOffset($0.location)?.minX return selection - } + }) + updateSelectionViews() + NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) + } + + public func addSelectedRange(_ range: NSRange) { + let selection = TextSelection(range: range) + guard !textSelections.contains(selection) else { return } + textSelections.insert(TextSelection(range: range)) updateSelectionViews() NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) } @@ -211,51 +227,7 @@ public class TextSelectionManager: NSObject { context.saveGState() context.setFillColor(selectionBackgroundColor.cgColor) - var fillRects = [CGRect]() - - for linePosition in layoutManager.lineStorage.linesInRange(range) { - if linePosition.range.intersection(range) == linePosition.range { - // If the selected range contains the entire line - fillRects.append(CGRect( - x: rect.minX, - y: linePosition.yPos, - width: rect.width, - height: linePosition.height - )) - } else { - // The selected range contains some portion of the line - for fragmentPosition in linePosition.data.lineFragments { - guard let fragmentRange = fragmentPosition - .range - .shifted(by: linePosition.range.location), - let intersectionRange = fragmentRange.intersection(range), - let minRect = layoutManager.rectForOffset(intersectionRange.location) else { - continue - } - - let maxRect: CGRect - if fragmentRange.max <= range.max || range.contains(fragmentRange.max) { - maxRect = CGRect( - x: rect.maxX, - y: fragmentPosition.yPos + linePosition.yPos, - width: 0, - height: fragmentPosition.height - ) - } else if let maxFragmentRect = layoutManager.rectForOffset(intersectionRange.max) { - maxRect = maxFragmentRect - } else { - continue - } - - fillRects.append(CGRect( - x: minRect.origin.x, - y: minRect.origin.y, - width: maxRect.minX - minRect.minX, - height: max(minRect.height, maxRect.height) - )) - } - } - } + let fillRects = getFillRects(in: rect, for: textSelection) let min = fillRects.min(by: { $0.origin.y < $1.origin.y })?.origin ?? .zero let max = fillRects.max(by: { $0.origin.y < $1.origin.y }) ?? .zero @@ -265,6 +237,98 @@ public class TextSelectionManager: NSObject { context.fill(fillRects) context.restoreGState() } + + /// Calculate a set of rects for a text selection suitable for highlighting the selection. + /// - Parameters: + /// - rect: The bounding rect of available draw space. + /// - textSelection: The selection to use. + /// - Returns: An array of rects that the selection overlaps. + func getFillRects(in rect: NSRect, for textSelection: TextSelection) -> [CGRect] { + guard let layoutManager else { return [] } + let range = textSelection.range + + var fillRects: [CGRect] = [] + guard let firstLinePosition = layoutManager.lineStorage.getLine(atOffset: range.location), + let lastLinePosition = range.max == layoutManager.lineStorage.length + ? layoutManager.lineStorage.last + : layoutManager.lineStorage.getLine(atOffset: range.max) else { + return [] + } + + // Calculate the first line and any rects selected + // If the last line position is not the same as the first, calculate any rects from that line. + // If there's > 0 space between the first and last positions, add a rect between them to cover any + // intermediate lines. + + fillRects.append(contentsOf: getFillRects(in: rect, selectionRange: range, forPosition: firstLinePosition)) + + if lastLinePosition.range != firstLinePosition.range { + fillRects.append(contentsOf: getFillRects(in: rect, selectionRange: range, forPosition: lastLinePosition)) + } + + if firstLinePosition.yPos + firstLinePosition.height < lastLinePosition.yPos { + fillRects.append(CGRect( + x: rect.minX, + y: firstLinePosition.yPos + firstLinePosition.height, + width: rect.width, + height: lastLinePosition.yPos - (firstLinePosition.yPos + firstLinePosition.height) + )) + } + + return fillRects + } + + /// Find fill rects for a specific line position. + /// - Parameters: + /// - rect: The bounding rect of the overall view. + /// - range: The selected range to create fill rects for. + /// - linePosition: The line position to use. + /// - Returns: An array of rects that the selection overlaps. + private func getFillRects( + in rect: NSRect, + selectionRange range: NSRange, + forPosition linePosition: TextLineStorage.TextLinePosition + ) -> [CGRect] { + guard let layoutManager else { return [] } + var fillRects: [CGRect] = [] + + // The selected range contains some portion of the line + for fragmentPosition in linePosition.data.lineFragments { + guard let fragmentRange = fragmentPosition + .range + .shifted(by: linePosition.range.location), + let intersectionRange = fragmentRange.intersection(range), + let minRect = layoutManager.rectForOffset(intersectionRange.location) else { + continue + } + + let maxRect: CGRect + // If the selection is at the end of the line, or contains the end of the fragment, and is not the end + // of the document, we select the entire line to the right of the selection point. + if (fragmentRange.max <= range.max || range.contains(fragmentRange.max)) + && range.max != layoutManager.lineStorage.length { + maxRect = CGRect( + x: rect.maxX, + y: fragmentPosition.yPos + linePosition.yPos, + width: 0, + height: fragmentPosition.height + ) + } else if let maxFragmentRect = layoutManager.rectForOffset(intersectionRange.max) { + maxRect = maxFragmentRect + } else { + continue + } + + fillRects.append(CGRect( + x: minRect.origin.x, + y: minRect.origin.y, + width: maxRect.minX - minRect.minX, + height: max(minRect.height, maxRect.height) + )) + } + + return fillRects + } } // MARK: - Private TextSelection diff --git a/Sources/CodeEditInputView/TextView/TextView+Drag.swift b/Sources/CodeEditInputView/TextView/TextView+Drag.swift index 8dfd3fdf0..3841cfd60 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Drag.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Drag.swift @@ -1,32 +1,79 @@ // // TextView+Drag.swift -// // -// Created by Khan Winter on 9/19/23. +// +// Created by Khan Winter on 10/20/23. // import AppKit -import Common - -extension TextView { - public override func mouseDragged(with event: NSEvent) { - if mouseDragAnchor == nil { - mouseDragAnchor = convert(event.locationInWindow, from: nil) - super.mouseDragged(with: event) - } else { - guard let mouseDragAnchor, - let startPosition = layoutManager.textOffsetAtPoint(mouseDragAnchor), - let endPosition = layoutManager.textOffsetAtPoint(convert(event.locationInWindow, from: nil)) else { + +extension TextView: NSDraggingSource { + class DragSelectionGesture: NSPressGestureRecognizer { + override func mouseDown(with event: NSEvent) { + guard isEnabled, let view = self.view as? TextView, event.type == .leftMouseDown else { return } - selectionManager.setSelectedRange( - NSRange( - location: min(startPosition, endPosition), - length: max(startPosition, endPosition) - min(startPosition, endPosition) - ) - ) - setNeedsDisplay() - self.autoscroll(with: event) + + let clickPoint = view.convert(event.locationInWindow, from: nil) + let selectionRects = view.selectionManager.textSelections.filter({ !$0.range.isEmpty }).flatMap { + view.selectionManager.getFillRects(in: view.frame, for: $0) + } + if !selectionRects.contains(where: { $0.contains(clickPoint) }) { + state = .failed + } + + super.mouseDown(with: event) + } + } + + internal func setUpDragGesture() { + let dragGesture = DragSelectionGesture(target: self, action: #selector(dragGestureHandler(_:))) + dragGesture.minimumPressDuration = NSEvent.doubleClickInterval / 3 + dragGesture.isEnabled = isSelectable + addGestureRecognizer(dragGesture) + } + + @objc private func dragGestureHandler(_ sender: Any) { + let selectionRects = selectionManager.textSelections.filter({ !$0.range.isEmpty }).flatMap { + selectionManager.getFillRects(in: frame, for: $0) + } + // TODO: This SUcks + let minX = (selectionRects.min(by: { $0.minX < $1.minX })?.minX ?? 0.0) + let minY = selectionRects.min(by: { $0.minY < $1.minY })?.minY ?? 0.0 + let maxX = selectionRects.max(by: { $0.maxX < $1.maxX })?.maxX ?? 0.0 + let maxY = selectionRects.max(by: { $0.maxY < $1.maxY })?.maxY ?? 0.0 + let imageBounds = CGRect( + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY + ) + + guard let bitmap = bitmapImageRepForCachingDisplay(in: imageBounds) else { + return + } + + selectionRects.forEach { selectionRect in + self.cacheDisplay(in: selectionRect, to: bitmap) } + + let draggingImage = NSImage(cgImage: bitmap.cgImage!, size: imageBounds.size) + + let attributedString = selectionManager + .textSelections + .sorted(by: { $0.range.location < $1.range.location }) + .map { textStorage.attributedSubstring(from: $0.range) } + .reduce(NSMutableAttributedString(), { $0.append($1); return $0 }) + let draggingItem = NSDraggingItem(pasteboardWriter: attributedString) + draggingItem.setDraggingFrame(imageBounds, contents: draggingImage) + + beginDraggingSession(with: [draggingItem], event: NSApp.currentEvent!, source: self) + } + + public func draggingSession( + _ session: NSDraggingSession, + sourceOperationMaskFor context: NSDraggingContext + ) -> NSDragOperation { + context == .outsideApplication ? .copy : .move } } diff --git a/Sources/CodeEditInputView/TextView/TextView+Mouse.swift b/Sources/CodeEditInputView/TextView/TextView+Mouse.swift new file mode 100644 index 000000000..43947de74 --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+Mouse.swift @@ -0,0 +1,81 @@ +// +// TextView+Mouse.swift +// +// +// Created by Khan Winter on 9/19/23. +// + +import AppKit +import Common + +extension TextView { + override public func mouseDown(with event: NSEvent) { + // Set cursor + guard isSelectable, + event.type == .leftMouseDown, + let offset = layoutManager.textOffsetAtPoint(self.convert(event.locationInWindow, from: nil)) else { + super.mouseDown(with: event) + return + } + + switch event.clickCount { + case 1: + if event.modifierFlags.intersection(.deviceIndependentFlagsMask).isSuperset(of: [.control, .shift]) { + selectionManager.addSelectedRange(NSRange(location: offset, length: 0)) + } else { + selectionManager.setSelectedRange(NSRange(location: offset, length: 0)) + } + case 2: + selectWord(nil) + case 3: + selectLine(nil) + default: + break + } + + mouseDragTimer?.invalidate() + // https://cocoadev.github.io/AutoScrolling/ (fired at ~45Hz) + mouseDragTimer = Timer.scheduledTimer(withTimeInterval: 0.022, repeats: true) { [weak self] _ in + if let event = self?.window?.currentEvent, event.type == .leftMouseDragged { + self?.mouseDragged(with: event) + self?.autoscroll(with: event) + } + } + + if !self.isFirstResponder { + self.window?.makeFirstResponder(self) + } + } + + override public func mouseUp(with event: NSEvent) { + mouseDragAnchor = nil + mouseDragTimer?.invalidate() + mouseDragTimer = nil + super.mouseUp(with: event) + } + + override public func mouseDragged(with event: NSEvent) { + guard !(inputContext?.handleEvent(event) ?? false) && isSelectable else { + return + } + + if mouseDragAnchor == nil { + mouseDragAnchor = convert(event.locationInWindow, from: nil) + super.mouseDragged(with: event) + } else { + guard let mouseDragAnchor, + let startPosition = layoutManager.textOffsetAtPoint(mouseDragAnchor), + let endPosition = layoutManager.textOffsetAtPoint(convert(event.locationInWindow, from: nil)) else { + return + } + selectionManager.setSelectedRange( + NSRange( + location: min(startPosition, endPosition), + length: max(startPosition, endPosition) - min(startPosition, endPosition) + ) + ) + setNeedsDisplay() + self.autoscroll(with: event) + } + } +} diff --git a/Sources/CodeEditInputView/TextView/TextView+Select.swift b/Sources/CodeEditInputView/TextView/TextView+Select.swift new file mode 100644 index 000000000..70b063314 --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+Select.swift @@ -0,0 +1,28 @@ +// +// TextView+Select.swift +// +// +// Created by Khan Winter on 10/20/23. +// + +import AppKit + +extension TextView { + override public func selectAll(_ sender: Any?) { + selectionManager.setSelectedRange(documentRange) + needsDisplay = true + } + + override public func selectLine(_ sender: Any?) { + let newSelections = selectionManager.textSelections.map { + textStorage.lineRange(containing: $0.range.location) + } + selectionManager.setSelectedRanges(newSelections) + needsDisplay = true + } + + override public func selectWord(_ sender: Any?) { + // TODO: Select word + needsDisplay = true + } +} diff --git a/Sources/CodeEditInputView/TextView/TextView.swift b/Sources/CodeEditInputView/TextView/TextView.swift index 195d1860d..65b630d24 100644 --- a/Sources/CodeEditInputView/TextView/TextView.swift +++ b/Sources/CodeEditInputView/TextView/TextView.swift @@ -217,6 +217,7 @@ public class TextView: NSView, NSTextContent { _undoManager = CEUndoManager(textView: self) layoutManager.layoutLines() + setUpDragGesture() } /// Set a new text storage object for the view. @@ -289,7 +290,7 @@ public class TextView: NSView, NSTextContent { updateFrameIfNeeded() } - // MARK: - Interaction + // MARK: - Key Down override public func keyDown(with event: NSEvent) { guard isEditable else { @@ -306,37 +307,6 @@ public class TextView: NSView, NSTextContent { } } - override public func mouseDown(with event: NSEvent) { - // Set cursor - guard let offset = layoutManager.textOffsetAtPoint(self.convert(event.locationInWindow, from: nil)) else { - super.mouseDown(with: event) - return - } - if isSelectable { - selectionManager.setSelectedRange(NSRange(location: offset, length: 0)) - } - - mouseDragTimer?.invalidate() - // https://cocoadev.github.io/AutoScrolling/ (fired at ~45Hz) - mouseDragTimer = Timer.scheduledTimer(withTimeInterval: 0.022, repeats: true) { [weak self] _ in - if let event = self?.window?.currentEvent, event.type == .leftMouseDragged { - self?.mouseDragged(with: event) - self?.autoscroll(with: event) - } - } - - if !self.isFirstResponder { - self.window?.makeFirstResponder(self) - } - } - - override public func mouseUp(with event: NSEvent) { - mouseDragAnchor = nil - mouseDragTimer?.invalidate() - mouseDragTimer = nil - super.mouseUp(with: event) - } - // MARK: - Layout override public func draw(_ dirtyRect: NSRect) { diff --git a/Sources/CodeEditTextView/Extensions/NSFont+RulerFont.swift b/Sources/CodeEditTextView/Extensions/NSFont+RulerFont.swift index 1386de188..feae2e564 100644 --- a/Sources/CodeEditTextView/Extensions/NSFont+RulerFont.swift +++ b/Sources/CodeEditTextView/Extensions/NSFont+RulerFont.swift @@ -36,7 +36,7 @@ extension NSFont { ] let features = [alt4, alt6and9, monoSpaceDigits] - let descriptor = self.fontDescriptor.addingAttributes([.featureSettings: features, .fixedAdvance: fontAdvance]) + let descriptor = font.fontDescriptor.addingAttributes([.featureSettings: features, .fixedAdvance: fontAdvance]) return NSFont(descriptor: descriptor, size: 0) ?? font } } From d6f399c93c216037f38d5fe23ba9a7909365f41f Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 21 Oct 2023 01:59:36 -0500 Subject: [PATCH 59/75] Remove Xcode Baselines --- ...ABF49830-0162-46D3-AAD0-396278570495.plist | 32 ------------------ .../Info.plist | 33 ------------------- 2 files changed, 65 deletions(-) delete mode 100644 .swiftpm/xcode/xcshareddata/xcbaselines/CodeEditTextViewTests.xcbaseline/ABF49830-0162-46D3-AAD0-396278570495.plist delete mode 100644 .swiftpm/xcode/xcshareddata/xcbaselines/CodeEditTextViewTests.xcbaseline/Info.plist 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 deleted file mode 100644 index 3b739b85f..000000000 --- a/.swiftpm/xcode/xcshareddata/xcbaselines/CodeEditTextViewTests.xcbaseline/ABF49830-0162-46D3-AAD0-396278570495.plist +++ /dev/null @@ -1,32 +0,0 @@ - - - - - classNames - - TextLayoutLineStorageTests - - test_insertFastPerformance() - - com.apple.XCTPerformanceMetric_WallClockTime - - baselineAverage - 0.232242 - baselineIntegrationDisplayName - Local Baseline - - - test_insertPerformance() - - com.apple.XCTPerformanceMetric_WallClockTime - - baselineAverage - 0.933000 - baselineIntegrationDisplayName - Local Baseline - - - - - - diff --git a/.swiftpm/xcode/xcshareddata/xcbaselines/CodeEditTextViewTests.xcbaseline/Info.plist b/.swiftpm/xcode/xcshareddata/xcbaselines/CodeEditTextViewTests.xcbaseline/Info.plist deleted file mode 100644 index f03b670cc..000000000 --- a/.swiftpm/xcode/xcshareddata/xcbaselines/CodeEditTextViewTests.xcbaseline/Info.plist +++ /dev/null @@ -1,33 +0,0 @@ - - - - - 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 - - - - From bdcd0f6ad7891884cedebdf696655ac7b69657a8 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sun, 22 Oct 2023 12:49:19 -0500 Subject: [PATCH 60/75] Multi-Cursor, Sync Cursor Animations, Line Highlights --- .../TextSelectionManager/CursorView.swift | 66 ++++++++++++-- .../TextSelectionManager+Update.swift | 44 ++++++++++ .../TextSelectionManager.swift | 85 ++++++++++--------- .../TextView/TextView+CopyPaste.swift | 9 +- .../TextView/TextView+ReplaceCharacters.swift | 4 +- .../TextView/TextView+Select.swift | 41 ++++++++- .../CodeEditInputView/TextView/TextView.swift | 2 - .../Extensions/NSFont+LineHeight.swift | 10 ++- .../CodeEditTextView/Gutter/GutterView.swift | 5 +- 9 files changed, 206 insertions(+), 60 deletions(-) create mode 100644 Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift diff --git a/Sources/CodeEditInputView/TextSelectionManager/CursorView.swift b/Sources/CodeEditInputView/TextSelectionManager/CursorView.swift index 40ff5d876..2b7aa4d48 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/CursorView.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/CursorView.swift @@ -7,8 +7,53 @@ import AppKit +fileprivate class CursorTimerService { + static let notification: NSNotification.Name = .init("com.CodeEdit.CursorTimerService.notification") + var timer: Timer? + var isHidden: Bool = false + var listeners: Int = 0 + + func setUpTimer(blinkDuration: TimeInterval?) { + assertMain() + timer?.invalidate() + timer = nil + isHidden = false + NotificationCenter.default.post(name: Self.notification, object: nil) + if let blinkDuration { + timer = Timer.scheduledTimer(withTimeInterval: blinkDuration, repeats: true, block: { [weak self] _ in + self?.timerReceived() + }) + } + listeners += 1 + } + + func timerReceived() { + assertMain() + isHidden.toggle() + NotificationCenter.default.post(name: Self.notification, object: nil) + } + + func destroySharedTimer() { + assertMain() + listeners -= 1 + if listeners == 0 { + timer?.invalidate() + timer = nil + isHidden = false + } + } + + private func assertMain() { +#if DEBUG + assert(Thread.isMainThread, "CursorTimerService used from non-main thread. This may cause a race condition.") +#endif + } +} + /// Animates a cursor. open class CursorView: NSView { + fileprivate static let timerService: CursorTimerService = CursorTimerService() + public var color: NSColor { didSet { layer?.backgroundColor = color.cgColor @@ -17,7 +62,7 @@ open class CursorView: NSView { private let blinkDuration: TimeInterval? private let width: CGFloat - private var timer: Timer? + private var observer: NSObjectProtocol? open override var isFlipped: Bool { true @@ -43,10 +88,14 @@ open class CursorView: NSView { wantsLayer = true layer?.backgroundColor = color.cgColor - if let blinkDuration { - timer = Timer.scheduledTimer(withTimeInterval: blinkDuration, repeats: true, block: { [weak self] _ in - self?.isHidden.toggle() - }) + CursorView.timerService.setUpTimer(blinkDuration: blinkDuration) + + observer = NotificationCenter.default.addObserver( + forName: CursorTimerService.notification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.isHidden = CursorView.timerService.isHidden } } @@ -55,7 +104,10 @@ open class CursorView: NSView { } deinit { - timer?.invalidate() - timer = nil + if let observer { + NotificationCenter.default.removeObserver(observer) + } + self.observer = nil + CursorView.timerService.destroySharedTimer() } } diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift new file mode 100644 index 000000000..7352754db --- /dev/null +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift @@ -0,0 +1,44 @@ +// +// TextSelectionManager+Update.swift +// +// +// Created by Khan Winter on 10/22/23. +// + +import Foundation + +extension TextSelectionManager { + internal func willReplaceCharacters(in range: NSRange, replacementLength: Int) { + let delta = replacementLength == 0 ? -range.length : replacementLength + for textSelection in self.textSelections { + if textSelection.range.location > range.max { + textSelection.range.location = max(0, textSelection.range.location + delta) + textSelection.range.length = 0 + } else if textSelection.range.intersection(range) != nil || textSelection.range == range { + if replacementLength > 0 { + textSelection.range.location = range.location + replacementLength + } else { + textSelection.range.location = range.location + } + textSelection.range.length = 0 + } else { + textSelection.range.length = 0 + } + } + + // Clean up duplicate selection ranges + var allRanges: Set = [] + for (i, selection) in self.textSelections.enumerated().reversed() { + if allRanges.contains(selection.range) { + self.textSelections.remove(at: i) + } else { + allRanges.insert(selection.range) + } + } + } + + internal func notifyAfterEdit() { + updateSelectionViews() + NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) + } +} diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift index d66eef086..d2fd8569a 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift @@ -86,7 +86,7 @@ public class TextSelectionManager: NSObject { public var selectionBackgroundColor: NSColor = NSColor.selectedTextBackgroundColor private var markedText: [MarkedText] = [] - private(set) public var textSelections: Set = [] + internal(set) public var textSelections: [TextSelection] = [] internal weak var layoutManager: TextLayoutManager? internal weak var textStorage: NSTextStorage? internal weak var layoutView: NSView? @@ -120,19 +120,34 @@ public class TextSelectionManager: NSObject { public func setSelectedRanges(_ ranges: [NSRange]) { textSelections.forEach { $0.view?.removeFromSuperview() } - textSelections = Set(ranges.map { + textSelections = Set(ranges).map { let selection = TextSelection(range: $0) selection.suggestedXPos = layoutManager?.rectForOffset($0.location)?.minX return selection - }) + } updateSelectionViews() NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) } public func addSelectedRange(_ range: NSRange) { - let selection = TextSelection(range: range) - guard !textSelections.contains(selection) else { return } - textSelections.insert(TextSelection(range: range)) + let newTextSelection = TextSelection(range: range) + var didHandle = false + for textSelection in textSelections { + if textSelection.range == newTextSelection.range { + // Duplicate range, ignore + return + } else if (range.length > 0 && textSelection.range.intersection(range) != nil) + || textSelection.range.max == range.location { + // Range intersects existing range, modify this range to be the union of both and don't add the new + // selection + textSelection.range = textSelection.range.union(range) + didHandle = true + } + } + if !didHandle { + textSelections.append(newTextSelection) + } + updateSelectionViews() NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) } @@ -149,6 +164,7 @@ public class TextSelectionManager: NSObject { || textSelection.boundingRect.origin != cursorOrigin || textSelection.boundingRect.height != layoutManager?.estimateLineHeight() ?? 0 { textSelection.view?.removeFromSuperview() + textSelection.view = nil let cursorView = CursorView(color: insertionPointColor) cursorView.frame.origin = cursorOrigin cursorView.frame.size.height = layoutManager?.estimateLineHeight() ?? 0 @@ -182,10 +198,16 @@ public class TextSelectionManager: NSObject { internal func drawSelections(in rect: NSRect) { guard let context = NSGraphicsContext.current?.cgContext else { return } context.saveGState() + var highlightedLines: Set = [] // For each selection in the rect for textSelection in textSelections { if textSelection.range.isEmpty { - drawHighlightedLine(in: rect, for: textSelection, context: context) + drawHighlightedLine( + in: rect, + for: textSelection, + context: context, + highlightedLines: &highlightedLines + ) } else { drawSelectedRange(in: rect, for: textSelection, context: context) } @@ -198,10 +220,19 @@ public class TextSelectionManager: NSObject { /// - rect: The rect to draw in. /// - textSelection: The selection to draw. /// - context: The context to draw in. - private func drawHighlightedLine(in rect: NSRect, for textSelection: TextSelection, context: CGContext) { - guard let linePosition = layoutManager?.textLineForOffset(textSelection.range.location) else { + /// - highlightedLines: The set of all lines that have already been highlighted, used to avoid highlighting lines + /// twice and updated if this function comes across a new line id. + private func drawHighlightedLine( + in rect: NSRect, + for textSelection: TextSelection, + context: CGContext, + highlightedLines: inout Set + ) { + guard let linePosition = layoutManager?.textLineForOffset(textSelection.range.location), + !highlightedLines.contains(linePosition.data.id) else { return } + highlightedLines.insert(linePosition.data.id) context.saveGState() let selectionRect = CGRect( x: rect.minX, @@ -237,7 +268,7 @@ public class TextSelectionManager: NSObject { context.fill(fillRects) context.restoreGState() } - + /// Calculate a set of rects for a text selection suitable for highlighting the selection. /// - Parameters: /// - rect: The bounding rect of available draw space. @@ -277,7 +308,7 @@ public class TextSelectionManager: NSObject { return fillRects } - + /// Find fill rects for a specific line position. /// - Parameters: /// - rect: The bounding rect of the overall view. @@ -306,7 +337,7 @@ public class TextSelectionManager: NSObject { // If the selection is at the end of the line, or contains the end of the fragment, and is not the end // of the document, we select the entire line to the right of the selection point. if (fragmentRange.max <= range.max || range.contains(fragmentRange.max)) - && range.max != layoutManager.lineStorage.length { + && intersectionRange.max != layoutManager.lineStorage.length { maxRect = CGRect( x: rect.maxX, y: fragmentPosition.yPos + linePosition.yPos, @@ -341,33 +372,3 @@ private extension TextSelectionManager.TextSelection { range.location += length } } - -// MARK: - Text Storage Delegate - -extension TextSelectionManager: NSTextStorageDelegate { - public 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 { - textSelection.range.location = editedRange.location - } - textSelection.range.length = 0 - } else { - textSelection.range.length = 0 - } - } - updateSelectionViews() - NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) - } -} diff --git a/Sources/CodeEditInputView/TextView/TextView+CopyPaste.swift b/Sources/CodeEditInputView/TextView/TextView+CopyPaste.swift index 4283b16b0..b89c25c2e 100644 --- a/Sources/CodeEditInputView/TextView/TextView+CopyPaste.swift +++ b/Sources/CodeEditInputView/TextView/TextView+CopyPaste.swift @@ -12,7 +12,9 @@ extension TextView { guard let textSelections = selectionManager? .textSelections .compactMap({ textStorage.attributedSubstring(from: $0.range) }), - !textSelections.isEmpty else { return } + !textSelections.isEmpty else { + return + } NSPasteboard.general.clearContents() NSPasteboard.general.writeObjects(textSelections) } @@ -23,10 +25,11 @@ extension TextView { } @objc open func cut(_ sender: AnyObject) { - + copy(sender) + deleteBackward(sender) } @objc open func delete(_ sender: AnyObject) { - + deleteBackward(sender) } } diff --git a/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift b/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift index 40cdbfc29..b06e9bc33 100644 --- a/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift +++ b/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift @@ -20,7 +20,7 @@ extension TextView { layoutManager.beginTransaction() textStorage.beginEditing() // Can't insert an empty string into an empty range. One must be not empty - for range in ranges where + for range in ranges.sorted(by: { $0.location > $1.location }) where (delegate?.textView(self, shouldReplaceContentsIn: range, with: string) ?? true) && (!range.isEmpty || !string.isEmpty) { delegate?.textView(self, willReplaceContentsIn: range, with: string) @@ -33,11 +33,13 @@ extension TextView { in: range, with: NSAttributedString(string: string, attributes: typingAttributes) ) + selectionManager.willReplaceCharacters(in: range, replacementLength: (string as NSString).length) delegate?.textView(self, didReplaceContentsIn: range, with: string) } layoutManager.endTransaction() textStorage.endEditing() + selectionManager.notifyAfterEdit() } /// Replace the characters in a range with a new string. diff --git a/Sources/CodeEditInputView/TextView/TextView+Select.swift b/Sources/CodeEditInputView/TextView/TextView+Select.swift index 70b063314..e2d8e8381 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Select.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Select.swift @@ -6,6 +6,7 @@ // import AppKit +import TextStory extension TextView { override public func selectAll(_ sender: Any?) { @@ -14,15 +15,49 @@ extension TextView { } override public func selectLine(_ sender: Any?) { - let newSelections = selectionManager.textSelections.map { - textStorage.lineRange(containing: $0.range.location) + let newSelections = selectionManager.textSelections.compactMap { textSelection -> NSRange? in + guard let linePosition = layoutManager.textLineForOffset(textSelection.range.location) else { + return nil + } + return linePosition.range } selectionManager.setSelectedRanges(newSelections) needsDisplay = true } override public func selectWord(_ sender: Any?) { - // TODO: Select word + let newSelections = selectionManager.textSelections.compactMap { (textSelection) -> NSRange? in + guard textSelection.range.isEmpty, + let char = textStorage.substring( + from: NSRange(location: textSelection.range.location, length: 1) + )?.first else { + return nil + } + let charSet = CharacterSet(charactersIn: String(char)) + let characterSet: CharacterSet + if CharacterSet.alphanumerics.isSuperset(of: charSet) { + characterSet = .alphanumerics + } else if CharacterSet.whitespaces.isSuperset(of: charSet) { + characterSet = .whitespaces + } else if CharacterSet.newlines.isSuperset(of: charSet) { + characterSet = .newlines + } else if CharacterSet.punctuationCharacters.isSuperset(of: charSet) { + characterSet = .punctuationCharacters + } else { + return nil + } + guard let start = textStorage + .findPrecedingOccurrenceOfCharacter(in: characterSet.inverted, from: textSelection.range.location), + let end = textStorage + .findNextOccurrenceOfCharacter(in: characterSet.inverted, from: textSelection.range.max) else { + return nil + } + return NSRange( + location: start, + length: end - start + ) + } + selectionManager.setSelectedRanges(newSelections) needsDisplay = true } } diff --git a/Sources/CodeEditInputView/TextView/TextView.swift b/Sources/CodeEditInputView/TextView/TextView.swift index 65b630d24..9383316cf 100644 --- a/Sources/CodeEditInputView/TextView/TextView.swift +++ b/Sources/CodeEditInputView/TextView/TextView.swift @@ -212,7 +212,6 @@ public class TextView: NSView, NSTextContent { layoutManager = setUpLayoutManager(lineHeight: lineHeight, wrapLines: wrapLines) storageDelegate.addDelegate(layoutManager) selectionManager = setUpSelectionManager() - storageDelegate.addDelegate(selectionManager) _undoManager = CEUndoManager(textView: self) @@ -228,7 +227,6 @@ public class TextView: NSView, NSTextContent { layoutManager = setUpLayoutManager(lineHeight: lineHeight, wrapLines: wrapLines) storageDelegate.addDelegate(layoutManager) selectionManager = setUpSelectionManager() - storageDelegate.addDelegate(selectionManager) _undoManager?.clearStack() needsDisplay = true needsLayout = true diff --git a/Sources/CodeEditTextView/Extensions/NSFont+LineHeight.swift b/Sources/CodeEditTextView/Extensions/NSFont+LineHeight.swift index 2dd9e5730..554e18495 100644 --- a/Sources/CodeEditTextView/Extensions/NSFont+LineHeight.swift +++ b/Sources/CodeEditTextView/Extensions/NSFont+LineHeight.swift @@ -6,10 +6,18 @@ // import AppKit +import CodeEditInputView public extension NSFont { /// The default line height of the font. var lineHeight: Double { - NSLayoutManager().defaultLineHeight(for: self) + let string = NSAttributedString(string: "0", attributes: [.font: self]) + 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 } } diff --git a/Sources/CodeEditTextView/Gutter/GutterView.swift b/Sources/CodeEditTextView/Gutter/GutterView.swift index 318074cec..a9655f877 100644 --- a/Sources/CodeEditTextView/Gutter/GutterView.swift +++ b/Sources/CodeEditTextView/Gutter/GutterView.swift @@ -130,13 +130,16 @@ public class GutterView: NSView { return } context.saveGState() + var highlightedLines: Set = [] context.setFillColor(selectedLineColor.cgColor) for selection in selectionManager.textSelections where selection.range.isEmpty { guard let line = textView.layoutManager.textLineForOffset(selection.range.location), - (visibleRange.intersection(line.range) != nil || selection.range.location == textView.length) else { + (visibleRange.intersection(line.range) != nil || selection.range.location == textView.length), + !highlightedLines.contains(line.data.id) else { continue } + highlightedLines.insert(line.data.id) context.fill( CGRect( x: 0.0, From af81a4730a461b0c1e35d0ae9c5213339317265c Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sun, 22 Oct 2023 12:54:40 -0500 Subject: [PATCH 61/75] Fix Lint Errors --- .../TextSelectionManager/CursorView.swift | 82 +++++++------- .../TextSelectionManager+FillRects.swift | 102 ++++++++++++++++++ .../TextSelectionManager+Update.swift | 4 +- .../TextSelectionManager.swift | 92 ---------------- 4 files changed, 149 insertions(+), 131 deletions(-) create mode 100644 Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+FillRects.swift diff --git a/Sources/CodeEditInputView/TextSelectionManager/CursorView.swift b/Sources/CodeEditInputView/TextSelectionManager/CursorView.swift index 2b7aa4d48..a519939b7 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/CursorView.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/CursorView.swift @@ -7,61 +7,69 @@ import AppKit -fileprivate class CursorTimerService { - static let notification: NSNotification.Name = .init("com.CodeEdit.CursorTimerService.notification") - var timer: Timer? - var isHidden: Bool = false - var listeners: Int = 0 - - func setUpTimer(blinkDuration: TimeInterval?) { - assertMain() - timer?.invalidate() - timer = nil - isHidden = false - NotificationCenter.default.post(name: Self.notification, object: nil) - if let blinkDuration { - timer = Timer.scheduledTimer(withTimeInterval: blinkDuration, repeats: true, block: { [weak self] _ in - self?.timerReceived() - }) - } - listeners += 1 - } - - func timerReceived() { - assertMain() - isHidden.toggle() - NotificationCenter.default.post(name: Self.notification, object: nil) - } - - func destroySharedTimer() { - assertMain() - listeners -= 1 - if listeners == 0 { +/// Animates a cursor. Will sync animation with any other cursor views. +open class CursorView: NSView { + /// Used to sync the cursor view animations when there's multiple cursors. + /// - Note: Do not use any methods in this class from a non-main thread. + private class CursorTimerService { + static let notification: NSNotification.Name = .init("com.CodeEdit.CursorTimerService.notification") + var timer: Timer? + var isHidden: Bool = false + var listeners: Int = 0 + + func setUpTimer(blinkDuration: TimeInterval?) { + assertMain() timer?.invalidate() timer = nil isHidden = false + NotificationCenter.default.post(name: Self.notification, object: nil) + if let blinkDuration { + timer = Timer.scheduledTimer(withTimeInterval: blinkDuration, repeats: true, block: { [weak self] _ in + self?.timerReceived() + }) + } + listeners += 1 } - } - private func assertMain() { + func timerReceived() { + assertMain() + isHidden.toggle() + NotificationCenter.default.post(name: Self.notification, object: nil) + } + + func destroySharedTimer() { + assertMain() + listeners -= 1 + if listeners == 0 { + timer?.invalidate() + timer = nil + isHidden = false + } + } + + private func assertMain() { #if DEBUG - assert(Thread.isMainThread, "CursorTimerService used from non-main thread. This may cause a race condition.") + // swiftlint:disable:next line_length + assert(Thread.isMainThread, "CursorTimerService used from non-main thread. This may cause a race condition.") #endif + } } -} -/// Animates a cursor. -open class CursorView: NSView { - fileprivate static let timerService: CursorTimerService = CursorTimerService() + /// The shared timer service + private static let timerService: CursorTimerService = CursorTimerService() + /// The color of the cursor. public var color: NSColor { didSet { layer?.backgroundColor = color.cgColor } } + /// How often the cursor toggles it's visibility. Leave `nil` to never blink. private let blinkDuration: TimeInterval? + /// The width of the cursor. private let width: CGFloat + /// The timer observer. private var observer: NSObjectProtocol? open override var isFlipped: Bool { diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+FillRects.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+FillRects.swift new file mode 100644 index 000000000..d4c45d53f --- /dev/null +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+FillRects.swift @@ -0,0 +1,102 @@ +// +// TextSelectionManager+FillRects.swift +// +// +// Created by Khan Winter on 10/22/23. +// + +import Foundation + +extension TextSelectionManager { + /// Calculate a set of rects for a text selection suitable for highlighting the selection. + /// - Parameters: + /// - rect: The bounding rect of available draw space. + /// - textSelection: The selection to use. + /// - Returns: An array of rects that the selection overlaps. + func getFillRects(in rect: NSRect, for textSelection: TextSelection) -> [CGRect] { + guard let layoutManager else { return [] } + let range = textSelection.range + + var fillRects: [CGRect] = [] + guard let firstLinePosition = layoutManager.lineStorage.getLine(atOffset: range.location), + let lastLinePosition = range.max == layoutManager.lineStorage.length + ? layoutManager.lineStorage.last + : layoutManager.lineStorage.getLine(atOffset: range.max) else { + return [] + } + + // Calculate the first line and any rects selected + // If the last line position is not the same as the first, calculate any rects from that line. + // If there's > 0 space between the first and last positions, add a rect between them to cover any + // intermediate lines. + + fillRects.append(contentsOf: getFillRects(in: rect, selectionRange: range, forPosition: firstLinePosition)) + + if lastLinePosition.range != firstLinePosition.range { + fillRects.append(contentsOf: getFillRects(in: rect, selectionRange: range, forPosition: lastLinePosition)) + } + + if firstLinePosition.yPos + firstLinePosition.height < lastLinePosition.yPos { + fillRects.append(CGRect( + x: rect.minX, + y: firstLinePosition.yPos + firstLinePosition.height, + width: rect.width, + height: lastLinePosition.yPos - (firstLinePosition.yPos + firstLinePosition.height) + )) + } + + return fillRects + } + + /// Find fill rects for a specific line position. + /// - Parameters: + /// - rect: The bounding rect of the overall view. + /// - range: The selected range to create fill rects for. + /// - linePosition: The line position to use. + /// - Returns: An array of rects that the selection overlaps. + private func getFillRects( + in rect: NSRect, + selectionRange range: NSRange, + forPosition linePosition: TextLineStorage.TextLinePosition + ) -> [CGRect] { + guard let layoutManager else { return [] } + var fillRects: [CGRect] = [] + + // The selected range contains some portion of the line + for fragmentPosition in linePosition.data.lineFragments { + guard let fragmentRange = fragmentPosition + .range + .shifted(by: linePosition.range.location), + let intersectionRange = fragmentRange.intersection(range), + let minRect = layoutManager.rectForOffset(intersectionRange.location) else { + continue + } + + let maxRect: CGRect + // If the selection is at the end of the line, or contains the end of the fragment, and is not the end + // of the document, we select the entire line to the right of the selection point. + if (fragmentRange.max <= range.max || range.contains(fragmentRange.max)) + && intersectionRange.max != layoutManager.lineStorage.length { + maxRect = CGRect( + x: rect.maxX, + y: fragmentPosition.yPos + linePosition.yPos, + width: 0, + height: fragmentPosition.height + ) + } else if let maxFragmentRect = layoutManager.rectForOffset(intersectionRange.max) { + maxRect = maxFragmentRect + } else { + continue + } + + fillRects.append(CGRect( + x: minRect.origin.x, + y: minRect.origin.y, + width: maxRect.minX - minRect.minX, + height: max(minRect.height, maxRect.height) + )) + } + + return fillRects + } +} diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift index 7352754db..999e80865 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift @@ -28,9 +28,9 @@ extension TextSelectionManager { // Clean up duplicate selection ranges var allRanges: Set = [] - for (i, selection) in self.textSelections.enumerated().reversed() { + for (idx, selection) in self.textSelections.enumerated().reversed() { if allRanges.contains(selection.range) { - self.textSelections.remove(at: i) + self.textSelections.remove(at: idx) } else { allRanges.insert(selection.range) } diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift index d2fd8569a..c7e52ba00 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift @@ -268,98 +268,6 @@ public class TextSelectionManager: NSObject { context.fill(fillRects) context.restoreGState() } - - /// Calculate a set of rects for a text selection suitable for highlighting the selection. - /// - Parameters: - /// - rect: The bounding rect of available draw space. - /// - textSelection: The selection to use. - /// - Returns: An array of rects that the selection overlaps. - func getFillRects(in rect: NSRect, for textSelection: TextSelection) -> [CGRect] { - guard let layoutManager else { return [] } - let range = textSelection.range - - var fillRects: [CGRect] = [] - guard let firstLinePosition = layoutManager.lineStorage.getLine(atOffset: range.location), - let lastLinePosition = range.max == layoutManager.lineStorage.length - ? layoutManager.lineStorage.last - : layoutManager.lineStorage.getLine(atOffset: range.max) else { - return [] - } - - // Calculate the first line and any rects selected - // If the last line position is not the same as the first, calculate any rects from that line. - // If there's > 0 space between the first and last positions, add a rect between them to cover any - // intermediate lines. - - fillRects.append(contentsOf: getFillRects(in: rect, selectionRange: range, forPosition: firstLinePosition)) - - if lastLinePosition.range != firstLinePosition.range { - fillRects.append(contentsOf: getFillRects(in: rect, selectionRange: range, forPosition: lastLinePosition)) - } - - if firstLinePosition.yPos + firstLinePosition.height < lastLinePosition.yPos { - fillRects.append(CGRect( - x: rect.minX, - y: firstLinePosition.yPos + firstLinePosition.height, - width: rect.width, - height: lastLinePosition.yPos - (firstLinePosition.yPos + firstLinePosition.height) - )) - } - - return fillRects - } - - /// Find fill rects for a specific line position. - /// - Parameters: - /// - rect: The bounding rect of the overall view. - /// - range: The selected range to create fill rects for. - /// - linePosition: The line position to use. - /// - Returns: An array of rects that the selection overlaps. - private func getFillRects( - in rect: NSRect, - selectionRange range: NSRange, - forPosition linePosition: TextLineStorage.TextLinePosition - ) -> [CGRect] { - guard let layoutManager else { return [] } - var fillRects: [CGRect] = [] - - // The selected range contains some portion of the line - for fragmentPosition in linePosition.data.lineFragments { - guard let fragmentRange = fragmentPosition - .range - .shifted(by: linePosition.range.location), - let intersectionRange = fragmentRange.intersection(range), - let minRect = layoutManager.rectForOffset(intersectionRange.location) else { - continue - } - - let maxRect: CGRect - // If the selection is at the end of the line, or contains the end of the fragment, and is not the end - // of the document, we select the entire line to the right of the selection point. - if (fragmentRange.max <= range.max || range.contains(fragmentRange.max)) - && intersectionRange.max != layoutManager.lineStorage.length { - maxRect = CGRect( - x: rect.maxX, - y: fragmentPosition.yPos + linePosition.yPos, - width: 0, - height: fragmentPosition.height - ) - } else if let maxFragmentRect = layoutManager.rectForOffset(intersectionRange.max) { - maxRect = maxFragmentRect - } else { - continue - } - - fillRects.append(CGRect( - x: minRect.origin.x, - y: minRect.origin.y, - width: maxRect.minX - minRect.minX, - height: max(minRect.height, maxRect.height) - )) - } - - return fillRects - } } // MARK: - Private TextSelection From 38db0d4fac421eecc7f041c928d9219b6c19e0e0 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sun, 22 Oct 2023 15:36:50 -0500 Subject: [PATCH 62/75] Fix Lint Errors --- Sources/CodeEditTextView/Gutter/GutterView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/Gutter/GutterView.swift b/Sources/CodeEditTextView/Gutter/GutterView.swift index a9655f877..aeca795b2 100644 --- a/Sources/CodeEditTextView/Gutter/GutterView.swift +++ b/Sources/CodeEditTextView/Gutter/GutterView.swift @@ -135,7 +135,7 @@ public class GutterView: NSView { for selection in selectionManager.textSelections where selection.range.isEmpty { guard let line = textView.layoutManager.textLineForOffset(selection.range.location), - (visibleRange.intersection(line.range) != nil || selection.range.location == textView.length), + visibleRange.intersection(line.range) != nil || selection.range.location == textView.length, !highlightedLines.contains(line.data.id) else { continue } From dae9e6031d1a9b55d680ac49f1a8093a883718a6 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 23 Oct 2023 20:36:05 -0500 Subject: [PATCH 63/75] Misc fixes, Fixup SwiftUI API --- .../TextSelectionManager+Update.swift | 2 +- .../TextSelectionManager.swift | 5 +- .../TextView/TextView+ReplaceCharacters.swift | 4 +- .../TextView/TextView+UndoRedo.swift | 4 + .../CodeEditInputView/TextView/TextView.swift | 47 +++++++++++- .../CodeEditTextView/CodeEditTextView.swift | 73 +++++++++++++++++-- .../TextViewController+LoadView.swift | 6 +- .../Controller/TextViewController.swift | 32 ++++++-- .../TextView+/TextView+TextFormation.swift | 4 + 9 files changed, 155 insertions(+), 22 deletions(-) diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift index 999e80865..796c07fc3 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift @@ -8,7 +8,7 @@ import Foundation extension TextSelectionManager { - internal func willReplaceCharacters(in range: NSRange, replacementLength: Int) { + public func willReplaceCharacters(in range: NSRange, replacementLength: Int) { let delta = replacementLength == 0 ? -range.length : replacementLength for textSelection in self.textSelections { if textSelection.range.location > range.max { diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift index c7e52ba00..1f7f75ee0 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift @@ -72,9 +72,8 @@ public class TextSelectionManager: NSObject { // MARK: - Properties - open class var selectionChangedNotification: Notification.Name { - Notification.Name("TextSelectionManager.TextSelectionChangedNotification") - } + // swiftlint:disable:next line_length + public static let selectionChangedNotification: Notification.Name = Notification.Name("com.CodeEdit.TextSelectionManager.TextSelectionChangedNotification") public var insertionPointColor: NSColor = NSColor.labelColor { didSet { diff --git a/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift b/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift index b06e9bc33..a043271db 100644 --- a/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift +++ b/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift @@ -17,9 +17,10 @@ extension TextView { /// - string: The string to insert in the ranges. public func replaceCharacters(in ranges: [NSRange], with string: String) { guard isEditable else { return } + NotificationCenter.default.post(name: Self.textWillChangeNotification, object: self) layoutManager.beginTransaction() textStorage.beginEditing() - // Can't insert an empty string into an empty range. One must be not empty + // Can't insert an ssempty string into an empty range. One must be not empty for range in ranges.sorted(by: { $0.location > $1.location }) where (delegate?.textView(self, shouldReplaceContentsIn: range, with: string) ?? true) && (!range.isEmpty || !string.isEmpty) { @@ -40,6 +41,7 @@ extension TextView { layoutManager.endTransaction() textStorage.endEditing() selectionManager.notifyAfterEdit() + NotificationCenter.default.post(name: Self.textDidChangeNotification, object: self) } /// Replace the characters in a range with a new string. diff --git a/Sources/CodeEditInputView/TextView/TextView+UndoRedo.swift b/Sources/CodeEditInputView/TextView/TextView+UndoRedo.swift index 830a66741..4e546d150 100644 --- a/Sources/CodeEditInputView/TextView/TextView+UndoRedo.swift +++ b/Sources/CodeEditInputView/TextView/TextView+UndoRedo.swift @@ -8,6 +8,10 @@ import AppKit extension TextView { + public func setUndoManager(_ newManager: CEUndoManager) { + self._undoManager = newManager + } + override public var undoManager: UndoManager? { _undoManager?.manager } diff --git a/Sources/CodeEditInputView/TextView/TextView.swift b/Sources/CodeEditInputView/TextView/TextView.swift index 9383316cf..66749f8fe 100644 --- a/Sources/CodeEditInputView/TextView/TextView.swift +++ b/Sources/CodeEditInputView/TextView/TextView.swift @@ -32,10 +32,16 @@ public class TextView: NSView, NSTextContent { /// - font: System font, size 12 /// - foregroundColor: System text color /// - kern: 0.0 - static public var defaultTypingAttributes: [NSAttributedString.Key: Any] { + public static var defaultTypingAttributes: [NSAttributedString.Key: Any] { [.font: NSFont.systemFont(ofSize: 12), .foregroundColor: NSColor.textColor, .kern: 0.0] } + // swiftlint:disable:next line_length + public static let textDidChangeNotification: Notification.Name = .init(rawValue: "com.CodeEdit.TextView.TextDidChangeNotification") + + // swiftlint:disable:next line_length + public static let textWillChangeNotification: Notification.Name = .init(rawValue: "com.CodeEdit.TextView.TextWillChangeNotification") + // MARK: - Configuration public var string: String { @@ -176,21 +182,49 @@ public class TextView: NSView, NSTextContent { // MARK: - Init - public init( + public convenience init( string: String, font: NSFont, textColor: NSColor, lineHeight: CGFloat, wrapLines: Bool, isEditable: Bool, + isSelectable: Bool, + letterSpacing: Double, + delegate: TextViewDelegate, + storageDelegate: MultiStorageDelegate + ) { + self.init( + textStorage: NSTextStorage(string: string), + font: font, + textColor: textColor, + lineHeight: lineHeight, + wrapLines: wrapLines, + isEditable: isEditable, + isSelectable: isSelectable, + letterSpacing: letterSpacing, + delegate: delegate, + storageDelegate: storageDelegate + ) + } + + public init( + textStorage: NSTextStorage, + font: NSFont, + textColor: NSColor, + lineHeight: CGFloat, + wrapLines: Bool, + isEditable: Bool, + isSelectable: Bool, letterSpacing: Double, delegate: TextViewDelegate, storageDelegate: MultiStorageDelegate ) { self.delegate = delegate - self.textStorage = NSTextStorage(string: string) + self.textStorage = textStorage self.storageDelegate = storageDelegate self.isEditable = isEditable + self.isSelectable = isSelectable self.letterSpacing = letterSpacing self.allowsUndo = true @@ -219,6 +253,13 @@ public class TextView: NSView, NSTextContent { setUpDragGesture() } + /// Sets the text view's text to a new value. + /// - Parameter text: The new contents of the text view. + public func setText(_ text: String) { + let newStorage = NSTextStorage(string: text) + self.setTextStorage(newStorage) + } + /// Set a new text storage object for the view. /// - Parameter textStorage: The new text storage to use. public func setTextStorage(_ textStorage: NSTextStorage) { diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index 77d6b6825..48c4acbc5 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditTextView/CodeEditTextView.swift @@ -30,6 +30,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable { /// - contentInsets: Insets to use to offset the content in the enclosing scroll view. Leave as `nil` to let the /// scroll view automatically adjust content insets. /// - isEditable: A Boolean value that controls whether the text view allows the user to edit text. + /// - isSelectable: A Boolean value that controls whether the text view allows the user to select text. If this + /// value is true, and `isEditable` is false, the editor is selectable but not editable. /// - letterSpacing: The amount of space to use between letters, as a percent. Eg: `1.0` = no space, `1.5` = 1/2 a /// character's width between characters, etc. Defaults to `1.0` /// - bracketPairHighlight: The type of highlight to use to highlight bracket pairs. @@ -50,6 +52,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { highlightProvider: HighlightProviding? = nil, contentInsets: NSEdgeInsets? = nil, isEditable: Bool = true, + isSelectable: Bool = true, letterSpacing: Double = 1.0, bracketPairHighlight: BracketPairHighlight? = nil, undoManager: CEUndoManager? = nil @@ -68,9 +71,10 @@ public struct CodeEditTextView: NSViewControllerRepresentable { self.highlightProvider = highlightProvider self.contentInsets = contentInsets self.isEditable = isEditable + self.isSelectable = isSelectable self.letterSpacing = letterSpacing self.bracketPairHighlight = bracketPairHighlight - self.undoManager = undoManager ?? CEUndoManager() + self.undoManager = undoManager } @Binding private var text: String @@ -87,15 +91,16 @@ public struct CodeEditTextView: NSViewControllerRepresentable { private var highlightProvider: HighlightProviding? private var contentInsets: NSEdgeInsets? private var isEditable: Bool + private var isSelectable: Bool private var letterSpacing: Double private var bracketPairHighlight: BracketPairHighlight? - private var undoManager: CEUndoManager + private var undoManager: CEUndoManager? public typealias NSViewControllerType = TextViewController public func makeNSViewController(context: Context) -> TextViewController { - return TextViewController( - string: $text, + let controller = TextViewController( + string: _text.wrappedValue, language: language, font: font, theme: theme, @@ -109,10 +114,17 @@ public struct CodeEditTextView: NSViewControllerRepresentable { highlightProvider: highlightProvider, contentInsets: contentInsets, isEditable: isEditable, + isSelectable: isSelectable, letterSpacing: letterSpacing, bracketPairHighlight: bracketPairHighlight, undoManager: undoManager ) + context.coordinator.controller = controller + return controller + } + + public func makeCoordinator() -> Coordinator { + Coordinator(parent: self) } public func updateNSViewController(_ controller: TextViewController, context: Context) { @@ -122,6 +134,13 @@ public struct CodeEditTextView: NSViewControllerRepresentable { return } + if !context.coordinator.isUpdateFromTextView { + // Prevent infinite loop of update notifications + context.coordinator.isUpdatingFromRepresentable = true + controller.setText(_text.wrappedValue) + context.coordinator.isUpdatingFromRepresentable = false + } + controller.font = font controller.wrapLines = wrapLines controller.useThemeBackground = useThemeBackground @@ -132,9 +151,13 @@ public struct CodeEditTextView: NSViewControllerRepresentable { controller.isEditable = isEditable } -// if controller.language.id != language.id { -// controller.language = language -// } + if controller.isSelectable != isSelectable { + controller.isSelectable = isSelectable + } + + if controller.language.id != language.id { + controller.language = language + } if controller.theme != theme { controller.theme = theme } @@ -157,16 +180,50 @@ public struct CodeEditTextView: NSViewControllerRepresentable { func paramsAreEqual(controller: NSViewControllerType) -> Bool { controller.font == font && controller.isEditable == isEditable && + controller.isSelectable == isSelectable && controller.wrapLines == wrapLines && controller.useThemeBackground == useThemeBackground && controller.lineHeightMultiple == lineHeight && controller.editorOverscroll == editorOverscroll && controller.contentInsets == contentInsets && -// controller.language.id == language.id && + controller.language.id == language.id && controller.theme == theme && controller.indentOption == indentOption && controller.tabWidth == tabWidth && controller.letterSpacing == letterSpacing && controller.bracketPairHighlight == bracketPairHighlight } + + public class Coordinator: NSObject { + var parent: CodeEditTextView + var controller: TextViewController? + var isUpdatingFromRepresentable: Bool = false + var isUpdateFromTextView: Bool = false + + init(parent: CodeEditTextView) { + self.parent = parent + super.init() + + NotificationCenter.default.addObserver( + self, + selector: #selector(textViewDidChangeText(_:)), + name: TextView.textDidChangeNotification, + object: nil + ) + } + + @objc func textViewDidChangeText(_ notification: Notification) { + guard let textView = notification.object as? TextView, + controller?.textView === textView, + !isUpdatingFromRepresentable else { + return + } + isUpdateFromTextView = true + parent.text = textView.string + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + } } diff --git a/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift b/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift index 6fef69b15..eb9239e32 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift @@ -13,12 +13,13 @@ extension TextViewController { override public func loadView() { scrollView = NSScrollView() textView = TextView( - string: string.wrappedValue, + textStorage: textStorage, font: font, textColor: theme.text, lineHeight: lineHeightMultiple, wrapLines: wrapLines, isEditable: isEditable, + isSelectable: isSelectable, letterSpacing: letterSpacing, delegate: self, storageDelegate: storageDelegate @@ -52,6 +53,9 @@ extension TextViewController { ) self.view = scrollView + if let _undoManager { + textView.setUndoManager(_undoManager) + } setUpHighlighter() setUpTextFormation() diff --git a/Sources/CodeEditTextView/Controller/TextViewController.swift b/Sources/CodeEditTextView/Controller/TextViewController.swift index ab9a898fa..71faa84e3 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController.swift @@ -17,12 +17,15 @@ public class TextViewController: NSViewController { var scrollView: NSScrollView! var textView: TextView! var gutterView: GutterView! + internal var _undoManager: CEUndoManager? /// Internal reference to any injected layers in the text view. internal var highlightLayers: [CALayer] = [] internal var systemAppearance: NSAppearance.Name? - /// Binding for the `textView`s string - public var string: Binding + /// The string contents. + public var string: String { + textStorage.string + } /// The associated `CodeLanguage` public var language: CodeLanguage { @@ -98,6 +101,13 @@ public class TextViewController: NSViewController { } } + /// Whether or not text view is selectable by user + public var isSelectable: Bool { + didSet { + textView.isSelectable = isSelectable + } + } + /// A multiplier that determines the amount of space between characters. `1.0` indicates no space, /// `2.0` indicates one character of space between other characters. public var letterSpacing: Double = 1.0 { @@ -113,6 +123,7 @@ public class TextViewController: NSViewController { } } + internal var textStorage: NSTextStorage internal var storageDelegate: MultiStorageDelegate! internal var highlighter: Highlighter? @@ -138,7 +149,7 @@ public class TextViewController: NSViewController { // MARK: Init init( - string: Binding, + string: String, language: CodeLanguage, font: NSFont, theme: EditorTheme, @@ -152,10 +163,12 @@ public class TextViewController: NSViewController { highlightProvider: HighlightProviding?, contentInsets: NSEdgeInsets?, isEditable: Bool, + isSelectable: Bool, letterSpacing: Double, - bracketPairHighlight: BracketPairHighlight? + bracketPairHighlight: BracketPairHighlight?, + undoManager: CEUndoManager? ) { - self.string = string + self.textStorage = NSTextStorage(string: string) self.language = language self.font = font self.theme = theme @@ -169,8 +182,10 @@ public class TextViewController: NSViewController { self.highlightProvider = highlightProvider self.contentInsets = contentInsets self.isEditable = isEditable + self.isSelectable = isSelectable self.letterSpacing = letterSpacing self.bracketPairHighlight = bracketPairHighlight + self._undoManager = undoManager self.storageDelegate = MultiStorageDelegate() @@ -181,6 +196,12 @@ public class TextViewController: NSViewController { fatalError("init(coder:) has not been implemented") } + /// Set the contents of the editor. + /// - Parameter text: The new contents of the editor. + public func setText(_ text: String) { + self.textView.setText(text) + } + // MARK: Paragraph Style /// A default `NSParagraphStyle` with a set `lineHeight` @@ -198,6 +219,7 @@ public class TextViewController: NSViewController { func reloadUI() { textView.isEditable = isEditable + textView.isSelectable = isSelectable textView.selectionManager.selectionBackgroundColor = theme.selection textView.selectionManager.selectedLineBackgroundColor = useThemeBackground diff --git a/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift b/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift index 92a5b6ab6..2c7ab0996 100644 --- a/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift +++ b/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift @@ -36,6 +36,10 @@ extension TextView: TextInterface { /// - Parameter mutation: The mutation to apply. public func applyMutation(_ mutation: TextMutation) { layoutManager.willReplaceCharactersInRange(range: mutation.range, with: mutation.string) + selectionManager.willReplaceCharacters( + in: mutation.range, + replacementLength: (mutation.string as NSString).length + ) textStorage.replaceCharacters(in: mutation.range, with: mutation.string) } } From 8a221c45fbd508a9154d01464a7baf92fcd89b44 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 23 Oct 2023 20:47:27 -0500 Subject: [PATCH 64/75] Fix Tests --- .../TextLayoutManager+Public.swift | 4 ++- .../Controller/TextViewController.swift | 2 +- .../Extensions/NSFont+LineHeight.swift | 9 +----- .../TextLayoutLineStorageTests.swift | 30 +++++++++---------- .../TextSelectionManagerTests.swift | 4 +-- .../TextViewControllerTests.swift | 3 +- 6 files changed, 24 insertions(+), 28 deletions(-) diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift index 791f04ee1..c553f650f 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift @@ -105,7 +105,9 @@ extension TextLayoutManager { // 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 realRange = (textStorage.string as NSString).rangeOfComposedCharacterSequence(at: offset) + let realRange = textStorage.length == 0 + ? NSRange(location: offset, length: 0) + : (textStorage.string as NSString).rangeOfComposedCharacterSequence(at: offset) let minXPos = CTLineGetOffsetForStringIndex( fragmentPosition.data.ctLine, diff --git a/Sources/CodeEditTextView/Controller/TextViewController.swift b/Sources/CodeEditTextView/Controller/TextViewController.swift index 71faa84e3..901e8702e 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController.swift @@ -166,7 +166,7 @@ public class TextViewController: NSViewController { isSelectable: Bool, letterSpacing: Double, bracketPairHighlight: BracketPairHighlight?, - undoManager: CEUndoManager? + undoManager: CEUndoManager? = nil ) { self.textStorage = NSTextStorage(string: string) self.language = language diff --git a/Sources/CodeEditTextView/Extensions/NSFont+LineHeight.swift b/Sources/CodeEditTextView/Extensions/NSFont+LineHeight.swift index 554e18495..18f9347c4 100644 --- a/Sources/CodeEditTextView/Extensions/NSFont+LineHeight.swift +++ b/Sources/CodeEditTextView/Extensions/NSFont+LineHeight.swift @@ -11,13 +11,6 @@ import CodeEditInputView public extension NSFont { /// The default line height of the font. var lineHeight: Double { - let string = NSAttributedString(string: "0", attributes: [.font: self]) - 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 + NSLayoutManager().defaultLineHeight(for: self) } } diff --git a/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift b/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift index db8d4aa2b..3c37db746 100644 --- a/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift +++ b/Tests/CodeEditInputViewTests/TextLayoutLineStorageTests.swift @@ -16,7 +16,7 @@ final class TextLayoutLineStorageTests: XCTestCase { let tree = TextLineStorage() var data = [TextLineStorage.BuildItem]() for idx in 0..<15 { - data.append(.init(data: TextLine(), length: idx + 1, height: 0.0)) + data.append(.init(data: TextLine(), length: idx + 1, height: 1.0)) } tree.build(from: data, estimatedLineHeight: 1.0) return tree @@ -92,36 +92,36 @@ final class TextLayoutLineStorageTests: XCTestCase { // Single Element tree.insert(line: TextLine(), atOffset: 0, length: 1, height: 1.0) tree.update(atIndex: 0, delta: 20, deltaHeight: 5.0) - XCTAssert(tree.length == 21, "Tree length incorrect") - XCTAssert(tree.count == 1, "Tree count incorrect") - XCTAssert(tree.height == 6, "Tree height incorrect") + XCTAssertEqual(tree.length, 21, "Tree length incorrect") + XCTAssertEqual(tree.count, 1, "Tree count incorrect") + XCTAssertEqual(tree.height, 6, "Tree height incorrect") XCTAssert(tree.root?.right == nil && tree.root?.left == nil, "Somehow inserted an extra node.") try assertTreeMetadataCorrect(tree) // Update First tree = createBalancedTree() tree.update(atIndex: 0, delta: 12, deltaHeight: -0.5) - XCTAssert(tree.height == 14.5, "Tree height incorrect") - XCTAssert(tree.count == 15, "Tree count changed") - XCTAssert(tree.length == 132, "Tree length incorrect") - XCTAssert(tree.first?.range.length == 13, "First node wasn't updated correctly.") + XCTAssertEqual(tree.height, 14.5, "Tree height incorrect") + XCTAssertEqual(tree.count, 15, "Tree count changed") + XCTAssertEqual(tree.length, 132, "Tree length incorrect") + XCTAssertEqual(tree.first?.range.length, 13, "First node wasn't updated correctly.") try assertTreeMetadataCorrect(tree) // Update Last tree = createBalancedTree() tree.update(atIndex: tree.length - 1, delta: -14, deltaHeight: 1.75) - XCTAssert(tree.height == 16.75, "Tree height incorrect") - XCTAssert(tree.count == 15, "Tree count changed") - XCTAssert(tree.length == 106, "Tree length incorrect") - XCTAssert(tree.last?.range.length == 1, "Last node wasn't updated correctly.") + XCTAssertEqual(tree.height, 16.75, "Tree height incorrect") + XCTAssertEqual(tree.count, 15, "Tree count changed") + XCTAssertEqual(tree.length, 106, "Tree length incorrect") + XCTAssertEqual(tree.last?.range.length, 1, "Last node wasn't updated correctly.") try assertTreeMetadataCorrect(tree) // Update middle tree = createBalancedTree() tree.update(atIndex: 45, delta: -9, deltaHeight: 1.0) - XCTAssert(tree.height == 16.0, "Tree height incorrect") - XCTAssert(tree.count == 15, "Tree count changed") - XCTAssert(tree.length == 111, "Tree length incorrect") + XCTAssertEqual(tree.height, 16.0, "Tree height incorrect") + XCTAssertEqual(tree.count, 15, "Tree count changed") + XCTAssertEqual(tree.length, 111, "Tree length incorrect") XCTAssert(tree.root?.right?.left?.height == 2.0 && tree.root?.right?.left?.length == 1, "Node wasn't updated") try assertTreeMetadataCorrect(tree) diff --git a/Tests/CodeEditInputViewTests/TextSelectionManagerTests.swift b/Tests/CodeEditInputViewTests/TextSelectionManagerTests.swift index df09bc5a7..4a1d4359a 100644 --- a/Tests/CodeEditInputViewTests/TextSelectionManagerTests.swift +++ b/Tests/CodeEditInputViewTests/TextSelectionManagerTests.swift @@ -53,8 +53,8 @@ final class TextSelectionManagerTests: XCTestCase { func test_updateSelectionRight() { let selectionManager = selectionManager() - let locations = [2, 0, 13, 12, 12] - let expectedRanges = [(2, 1), (0, 1), (13, 0), (12, 2), (12, 1)] + let locations = [2, 0, 14, 13, 12] + let expectedRanges = [(2, 1), (0, 1), (14, 0), (12, 2), (12, 1)] let decomposeCharacters = [false, false, false, false, true] for idx in locations.indices { diff --git a/Tests/CodeEditTextViewTests/TextViewControllerTests.swift b/Tests/CodeEditTextViewTests/TextViewControllerTests.swift index bbbde3267..f9159c634 100644 --- a/Tests/CodeEditTextViewTests/TextViewControllerTests.swift +++ b/Tests/CodeEditTextViewTests/TextViewControllerTests.swift @@ -31,7 +31,7 @@ final class TextViewControllerTests: XCTestCase { comments: .systemGreen ) controller = TextViewController( - string: .constant(""), + string: "", language: .default, font: .monospacedSystemFont(ofSize: 11, weight: .medium), theme: theme, @@ -45,6 +45,7 @@ final class TextViewControllerTests: XCTestCase { highlightProvider: nil, contentInsets: NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0), isEditable: true, + isSelectable: true, letterSpacing: 1.0, bracketPairHighlight: .flash ) From 9e0ca634d45559221fcfedd679bbaeca1f538f2f Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 2 Nov 2023 12:55:59 -0500 Subject: [PATCH 65/75] Correctly Reset `setTextStorage`, background colors --- .../xcschemes/CodeEditTextView.xcscheme | 10 +++++ .../TextLayoutManager+Edits.swift | 2 +- .../TextLayoutManager+Public.swift | 10 +++-- .../TextLayoutManager/TextLayoutManager.swift | 39 ++++++++++++------- .../TextSelectionManager.swift | 2 +- .../TextView/TextView+Setup.swift | 1 - .../TextView+TextLayoutManagerDelegate.swift | 4 ++ .../CodeEditInputView/TextView/TextView.swift | 21 +++++++--- .../TextViewController+Highlighter.swift | 5 +++ .../TextViewController+LoadView.swift | 2 + .../Controller/TextViewController.swift | 7 +++- 11 files changed, 75 insertions(+), 28 deletions(-) diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/CodeEditTextView.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/CodeEditTextView.xcscheme index e3a4330fd..3ac36c38c 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/CodeEditTextView.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/CodeEditTextView.xcscheme @@ -52,6 +52,16 @@ ReferencedContainer = "container:"> + + + + Int? { guard point.y <= estimatedHeight() else { // End position is a special case. - return textStorage.length + return textStorage?.length } guard let position = lineStorage.getLine(atPosition: point.y), let fragmentPosition = position.data.typesetter.lineFragments.getLine( @@ -62,7 +62,7 @@ extension TextLayoutManager { // If the endPosition is at the end of the line, and the line ends with a line ending character // return the index before the eol. if endPosition == position.range.max, - let lineEnding = LineEnding(line: textStorage.substring(from: globalFragmentRange) ?? "") { + let lineEnding = LineEnding(line: textStorage?.substring(from: globalFragmentRange) ?? "") { return endPosition - lineEnding.length } else { return endPosition @@ -105,9 +105,10 @@ extension TextLayoutManager { // 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 realRange = textStorage.length == 0 + let realRange = textStorage?.length == 0 ? NSRange(location: offset, length: 0) - : (textStorage.string as NSString).rangeOfComposedCharacterSequence(at: offset) + : (textStorage?.string as? NSString)?.rangeOfComposedCharacterSequence(at: offset) + ?? NSRange(location: offset, length: 0) let minXPos = CTLineGetOffsetForStringIndex( fragmentPosition.data.ctLine, @@ -186,6 +187,7 @@ extension TextLayoutManager { /// Forces layout calculation for all lines up to and including the given offset. /// - Parameter offset: The offset to ensure layout until. private func ensureLayoutFor(position: TextLineStorage.TextLinePosition) -> CGFloat { + guard let textStorage else { return 0 } position.data.prepareForDisplay( maxWidth: maxLineLayoutWidth, lineHeightMultiplier: lineHeightMultiplier, diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift index 472f2ab60..d2bc0f0cb 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift @@ -12,6 +12,7 @@ import Common public protocol TextLayoutManagerDelegate: AnyObject { func layoutManagerHeightDidUpdate(newHeight: CGFloat) func layoutManagerMaxWidthDidChange(newWidth: CGFloat) + func layoutManagerTypingAttributes() -> [NSAttributedString.Key: Any] func textViewportSize() -> CGSize func layoutManagerYAdjustment(_ yAdjustment: CGFloat) @@ -23,11 +24,6 @@ public class TextLayoutManager: NSObject { // MARK: - Public Properties public weak var delegate: TextLayoutManagerDelegate? - public var typingAttributes: [NSAttributedString.Key: Any] { - didSet { - _estimateLineHeight = nil - } - } public var lineHeightMultiplier: CGFloat { didSet { setNeedsLayout() @@ -54,7 +50,7 @@ public class TextLayoutManager: NSObject { // MARK: - Internal - internal unowned var textStorage: NSTextStorage + internal weak var textStorage: NSTextStorage? internal var lineStorage: TextLineStorage = TextLineStorage() private let viewReuseQueue: ViewReuseQueue = ViewReuseQueue() private var visibleLineIds: Set = [] @@ -87,30 +83,30 @@ public class TextLayoutManager: NSObject { /// 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. + /// - lineHeightMultiplier: The multiplier to use for line heights. + /// - wrapLines: Set to true to wrap lines to the visible editor width. + /// - textView: The view to layout text fragments in. + /// - delegate: A delegate for the layout manager. init( textStorage: NSTextStorage, - typingAttributes: [NSAttributedString.Key: Any], lineHeightMultiplier: CGFloat, wrapLines: Bool, textView: NSView, delegate: TextLayoutManagerDelegate? ) { self.textStorage = textStorage - 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() } /// 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 } + internal func prepareTextLines() { + guard lineStorage.count == 0, let textStorage else { return } #if DEBUG var info = mach_timebase_info() guard mach_timebase_info(&info) == KERN_SUCCESS else { return } @@ -127,6 +123,17 @@ public class TextLayoutManager: NSObject { print("Text Layout Manager built in: ", TimeInterval(nanos) / TimeInterval(NSEC_PER_MSEC), "ms") #endif } + + /// Resets the layout manager to an initial state. + internal func reset() { + lineStorage.removeAll() + visibleLineIds.removeAll() + viewReuseQueue.queuedViews.removeAll() + viewReuseQueue.usedViews.removeAll() + maxLineWidth = 0 + prepareTextLines() + setNeedsLayout() + } /// Estimates the line height for the current typing attributes. /// Takes into account ``TextLayoutManager/lineHeightMultiplier``. @@ -135,7 +142,7 @@ public class TextLayoutManager: NSObject { if let _estimateLineHeight { return _estimateLineHeight } else { - let string = NSAttributedString(string: "0", attributes: typingAttributes) + let string = NSAttributedString(string: "0", attributes: delegate?.layoutManagerTypingAttributes() ?? [:]) let typesetter = CTTypesetterCreateWithAttributedString(string) let ctLine = CTTypesetterCreateLine(typesetter, CFRangeMake(0, 1)) var ascent: CGFloat = 0 @@ -206,7 +213,7 @@ public class TextLayoutManager: NSObject { /// Lays out all visible lines internal func layoutLines() { // swiftlint:disable:this function_body_length - guard let visibleRect = delegate?.visibleRect, !isInTransaction else { return } + guard let visibleRect = delegate?.visibleRect, !isInTransaction, let textStorage else { return } let minY = max(visibleRect.minY, 0) let maxY = max(visibleRect.maxY, 0) let originalHeight = lineStorage.height @@ -225,6 +232,7 @@ public class TextLayoutManager: NSObject { || !visibleLineIds.contains(linePosition.data.id) { let lineSize = layoutLine( linePosition, + textStorage: textStorage, minY: linePosition.yPos, maxY: maxY, maxWidth: maxLineLayoutWidth, @@ -278,12 +286,15 @@ public class TextLayoutManager: NSObject { /// Lays out a single text line. /// - Parameters: /// - position: The line position from storage to use for layout. + /// - textStorage: The text storage object to use for text info. /// - minY: The minimum Y value to start at. /// - maxY: The maximum Y value to end layout at. + /// - maxWidth: The maximum layout width, infinite if ``TextLayoutManager/wrapLines`` is `false`. /// - 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. private func layoutLine( _ position: TextLineStorage.TextLinePosition, + textStorage: NSTextStorage, minY: CGFloat, maxY: CGFloat, maxWidth: CGFloat, diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift index 1f7f75ee0..44f36b1fe 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift @@ -84,7 +84,7 @@ public class TextSelectionManager: NSObject { public var selectedLineBackgroundColor: NSColor = NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) public var selectionBackgroundColor: NSColor = NSColor.selectedTextBackgroundColor - private var markedText: [MarkedText] = [] + internal var markedText: [MarkedText] = [] internal(set) public var textSelections: [TextSelection] = [] internal weak var layoutManager: TextLayoutManager? internal weak var textStorage: NSTextStorage? diff --git a/Sources/CodeEditInputView/TextView/TextView+Setup.swift b/Sources/CodeEditInputView/TextView/TextView+Setup.swift index 4298f7f82..605ef74c3 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Setup.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Setup.swift @@ -11,7 +11,6 @@ extension TextView { internal func setUpLayoutManager(lineHeight: CGFloat, wrapLines: Bool) -> TextLayoutManager { TextLayoutManager( textStorage: textStorage, - typingAttributes: typingAttributes, lineHeightMultiplier: lineHeight, wrapLines: wrapLines, textView: self, // TODO: This is an odd syntax... consider reworking this diff --git a/Sources/CodeEditInputView/TextView/TextView+TextLayoutManagerDelegate.swift b/Sources/CodeEditInputView/TextView/TextView+TextLayoutManagerDelegate.swift index 4a1ddd738..8298cf30e 100644 --- a/Sources/CodeEditInputView/TextView/TextView+TextLayoutManagerDelegate.swift +++ b/Sources/CodeEditInputView/TextView/TextView+TextLayoutManagerDelegate.swift @@ -16,6 +16,10 @@ extension TextView: TextLayoutManagerDelegate { updateFrameIfNeeded() } + public func layoutManagerTypingAttributes() -> [NSAttributedString.Key : Any] { + typingAttributes + } + public func textViewportSize() -> CGSize { if let scrollView = scrollView { var size = scrollView.contentSize diff --git a/Sources/CodeEditInputView/TextView/TextView.swift b/Sources/CodeEditInputView/TextView/TextView.swift index 66749f8fe..819a773ce 100644 --- a/Sources/CodeEditInputView/TextView/TextView.swift +++ b/Sources/CodeEditInputView/TextView/TextView.swift @@ -263,12 +263,23 @@ public class TextView: NSView, NSTextContent { /// Set a new text storage object for the view. /// - Parameter textStorage: The new text storage to use. public func setTextStorage(_ textStorage: NSTextStorage) { - let lineHeight = layoutManager.lineHeightMultiplier - let wrapLines = layoutManager.wrapLines - layoutManager = setUpLayoutManager(lineHeight: lineHeight, wrapLines: wrapLines) - storageDelegate.addDelegate(layoutManager) - selectionManager = setUpSelectionManager() + self.textStorage = textStorage + + subviews.forEach { view in + view.removeFromSuperview() + } + + textStorage.addAttributes(typingAttributes, range: documentRange) + layoutManager.textStorage = textStorage + layoutManager.reset() + + selectionManager.textStorage = textStorage + selectionManager.textSelections.removeAll() + selectionManager.markedText.removeAll() + _undoManager?.clearStack() + + textStorage.delegate = storageDelegate needsDisplay = true needsLayout = true } diff --git a/Sources/CodeEditTextView/Controller/TextViewController+Highlighter.swift b/Sources/CodeEditTextView/Controller/TextViewController+Highlighter.swift index 9a5ba648b..c6b442a9b 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController+Highlighter.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController+Highlighter.swift @@ -10,6 +10,11 @@ import SwiftTreeSitter extension TextViewController { internal func setUpHighlighter() { + if let highlighter { + storageDelegate.removeDelegate(highlighter) + self.highlighter = nil + } + self.highlighter = Highlighter( textView: textView, highlightProvider: highlightProvider, diff --git a/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift b/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift index eb9239e32..503065202 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift @@ -34,6 +34,7 @@ extension TextViewController { scrollView.hasHorizontalScroller = true scrollView.documentView = textView scrollView.contentView.postsBoundsChangedNotifications = true + scrollView.backgroundColor = useThemeBackground ? theme.background : .clear if let contentInsets { scrollView.automaticallyAdjustsContentInsets = false scrollView.contentInsets = contentInsets @@ -46,6 +47,7 @@ extension TextViewController { delegate: self ) gutterView.frame.origin.y = -scrollView.contentInsets.top + gutterView.backgroundColor = useThemeBackground ? theme.background : .textBackgroundColor gutterView.updateWidthIfNeeded() scrollView.addFloatingSubview( gutterView, diff --git a/Sources/CodeEditTextView/Controller/TextViewController.swift b/Sources/CodeEditTextView/Controller/TextViewController.swift index 901e8702e..fa0562710 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController.swift @@ -44,6 +44,8 @@ public class TextViewController: NSViewController { /// The associated `Theme` used for highlighting. public var theme: EditorTheme { didSet { + textView.layoutManager.setNeedsLayout() + textStorage.setAttributes(attributesFor(nil), range: NSRange(location: 0, length: textStorage.length)) highlighter?.invalidate() } } @@ -200,6 +202,7 @@ public class TextViewController: NSViewController { /// - Parameter text: The new contents of the editor. public func setText(_ text: String) { self.textView.setText(text) + self.setUpHighlighter() } // MARK: Paragraph Style @@ -236,7 +239,7 @@ public class TextViewController: NSViewController { : NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) gutterView.highlightSelectedLines = isEditable gutterView.font = font.rulerFont - gutterView.backgroundColor = theme.background + gutterView.backgroundColor = useThemeBackground ? theme.background : .textBackgroundColor if self.isEditable == false { gutterView.selectedLineTextColor = nil gutterView.selectedLineColor = .clear @@ -266,6 +269,6 @@ public class TextViewController: NSViewController { extension TextViewController: GutterViewDelegate { public func gutterViewWidthDidUpdate(newWidth: CGFloat) { gutterView?.frame.size.width = newWidth - textView?.edgeInsets.left = newWidth + textView?.edgeInsets = HorizontalEdgeInsets(left: newWidth, right: 0) } } From f0e6059b9c397bdbe217873800b6976d4e0dca1b Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 7 Nov 2023 04:32:55 -0600 Subject: [PATCH 66/75] Initial Marked Text implementation Still needs to discard marked ranges correctly on selection change. --- Package.swift | 15 ++++ .../Documentation.docc/Documentation.md | 37 ++++++++++ .../MarkedTextManager/MarkedTextManager.swift | 71 ++++++++++++++++++ .../TextLayoutManager+Public.swift | 3 +- .../TextLayoutManager/TextLayoutManager.swift | 5 +- .../CodeEditInputView/TextLine/TextLine.swift | 9 ++- .../TextLine/Typesetter.swift | 17 ++++- .../TextSelectionManager+Update.swift | 6 +- .../TextSelectionManager.swift | 6 -- .../TextView/TextView+Mouse.swift | 3 + .../TextView/TextView+NSTextInput.swift | 73 +++++++++++++------ .../TextView/TextView+ReplaceCharacters.swift | 2 +- .../CodeEditInputView/TextView/TextView.swift | 2 +- .../Utils/CEUndoManager.swift | 34 +++++++-- .../TextView+/TextView+TextFormation.swift | 2 +- .../TextSelectionManagerTests.swift | 1 - 16 files changed, 236 insertions(+), 50 deletions(-) create mode 100644 Sources/CodeEditInputView/Documentation.docc/Documentation.md create mode 100644 Sources/CodeEditInputView/MarkedTextManager/MarkedTextManager.swift diff --git a/Package.swift b/Package.swift index 8e52d7a75..9e6b9f35e 100644 --- a/Package.swift +++ b/Package.swift @@ -7,30 +7,41 @@ let package = Package( name: "CodeEditTextView", platforms: [.macOS(.v13)], products: [ + // A source editor with useful features for code editing. .library( name: "CodeEditTextView", targets: ["CodeEditTextView"] ), + // A Fast, Efficient text view for code. + .library( + name: "CodeEditInputView", + targets: ["CodeEditInputView"] + ) ], dependencies: [ + // tree-sitter languages .package( url: "https://github.com/CodeEditApp/CodeEditLanguages.git", exact: "0.1.17" ), + // SwiftLint .package( url: "https://github.com/lukepistrol/SwiftLintPlugin", from: "0.2.2" ), + // Filters for indentation, pair completion, whitespace .package( url: "https://github.com/ChimeHQ/TextFormation", from: "0.7.0" ), + // Useful data structures .package( url: "https://github.com/apple/swift-collections.git", .upToNextMajor(from: "1.0.0") ) ], targets: [ + // A source editor with useful features for code editing. .target( name: "CodeEditTextView", dependencies: [ @@ -44,6 +55,7 @@ let package = Package( ] ), + // The underlying text rendering view for CodeEditTextView .target( name: "CodeEditInputView", dependencies: [ @@ -55,6 +67,7 @@ let package = Package( ] ), + // Common classes and extensions used in both CodeEditTextView and CodeEditInputView .target( name: "Common", dependencies: [ @@ -65,6 +78,7 @@ let package = Package( ] ), + // Tests for the source editor .testTarget( name: "CodeEditTextViewTests", dependencies: [ @@ -76,6 +90,7 @@ let package = Package( ] ), + // Tests for the input view .testTarget( name: "CodeEditInputViewTests", dependencies: [ diff --git a/Sources/CodeEditInputView/Documentation.docc/Documentation.md b/Sources/CodeEditInputView/Documentation.docc/Documentation.md new file mode 100644 index 000000000..d7ffa9dd1 --- /dev/null +++ b/Sources/CodeEditInputView/Documentation.docc/Documentation.md @@ -0,0 +1,37 @@ +# ``CodeEditInputView`` + +A text editor designed to edit code documents. + +## Overview + +A text editor specialized for displaying and editing code documents. Features include basic text editing, extremely fast initial layout, support for handling large documents, customization options for code documents. + +> This package contains a text view suitable for replacing `NSTextView` in some, ***specific*** cases. If you want a text view that can handle things like: left-to-right layout, custom layout elements, or feature parity with the system text view, consider using [STTextView](https://github.com/krzyzanowskim/STTextView) or [NSTextView](https://developer.apple.com/documentation/appkit/nstextview). The ``TextView`` exported by this library is designed to lay out documents made up of lines of text. However, it does not attempt to reason about the contents of the document. If you're looking to edit *source code* (indentation, syntax highlighting) consider using the parent library [CodeEditTextView](https://github.com/CodeEditApp/CodeEditTextView). + +The ``TextView`` class is an `NSView` subclass that can be embedded in a scroll view or used standalone. It parses and renders lines of a document and handles mouse and keyboard events for text editing. It also renders styled strings for use cases like syntax highlighting. + +## Topics + +### Text View + +- ``TextView`` +- ``CEUndoManager`` + +### Text Layout + +- ``TextLayoutManager`` +- ``TextLine`` +- ``LineFragment`` + +### Text Selection + +- ``TextSelectionManager`` +- ``TextSelectionManager/TextSelection`` +- ``CursorView`` + +### Supporting Types + +- ``TextLineStorage`` +- ``HorizontalEdgeInsets`` +- ``LineEnding`` +- ``LineBreakStrategy`` diff --git a/Sources/CodeEditInputView/MarkedTextManager/MarkedTextManager.swift b/Sources/CodeEditInputView/MarkedTextManager/MarkedTextManager.swift new file mode 100644 index 000000000..0a79509d8 --- /dev/null +++ b/Sources/CodeEditInputView/MarkedTextManager/MarkedTextManager.swift @@ -0,0 +1,71 @@ +// +// MarkedTextManager.swift +// +// +// Created by Khan Winter on 11/7/23. +// + +import AppKit + +/// Manages marked ranges +class MarkedTextManager { + struct MarkedRanges { + let ranges: [NSRange] + let attributes: [NSAttributedString.Key: Any] + } + + /// All marked ranges being tracked. + private(set) var markedRanges: [NSRange] = [] + + /// The attributes to use for marked text. Defaults to a single underline when `nil` + var markedTextAttributes: [NSAttributedString.Key: Any]? + + /// True if there is marked text being tracked. + var hasMarkedText: Bool { + !markedRanges.isEmpty + } + + /// Removes all marked ranges. + func removeAll() { + markedRanges.removeAll() + } + + /// Updates the stored marked ranges. + /// - Parameters: + /// - insertLength: The length of the string being inserted. + /// - replacementRange: The range to replace with marked text. + /// - selectedRange: The selected range from `NSTextInput`. + /// - textSelections: The current text selections. + func updateMarkedRanges( + insertLength: Int, + replacementRange: NSRange, + selectedRange: NSRange, + textSelections: [TextSelectionManager.TextSelection] + ) { + if replacementRange.location == NSNotFound { + markedRanges = textSelections.map { + NSRange(location: $0.range.location, length: insertLength) + } + } else { + markedRanges = [selectedRange] + } + } + + /// Finds any marked ranges for a line and returns them. + /// - Parameter lineRange: The range of the line. + /// - Returns: A `MarkedRange` struct with information about attributes and ranges. `nil` if there is no marked + /// text for this line. + func markedRanges(in lineRange: NSRange) -> MarkedRanges? { + let attributes = markedTextAttributes ?? [.underlineStyle: NSUnderlineStyle.single.rawValue] + let ranges = markedRanges.compactMap { + $0.intersection(lineRange) + }.map { + NSRange(location: $0.location - lineRange.location, length: $0.length) + } + if ranges.isEmpty { + return nil + } else { + return MarkedRanges(ranges: ranges, attributes: attributes) + } + } +} diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift index 02ccb9649..8e1fc5c79 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift @@ -193,7 +193,8 @@ extension TextLayoutManager { lineHeightMultiplier: lineHeightMultiplier, estimatedLineHeight: estimateLineHeight(), range: position.range, - stringRef: textStorage + stringRef: textStorage, + markedRanges: markedTextManager.markedRanges(in: position.range) ) var height: CGFloat = 0 for fragmentPosition in position.data.lineFragments { diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift index d2bc0f0cb..04a697c32 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift @@ -52,6 +52,7 @@ public class TextLayoutManager: NSObject { internal weak var textStorage: NSTextStorage? internal var lineStorage: TextLineStorage = TextLineStorage() + var markedTextManager: MarkedTextManager = MarkedTextManager() private let viewReuseQueue: ViewReuseQueue = ViewReuseQueue() private var visibleLineIds: Set = [] /// Used to force a complete re-layout using `setNeedsLayout` @@ -131,6 +132,7 @@ public class TextLayoutManager: NSObject { viewReuseQueue.queuedViews.removeAll() viewReuseQueue.usedViews.removeAll() maxLineWidth = 0 + markedTextManager.removeAll() prepareTextLines() setNeedsLayout() } @@ -306,7 +308,8 @@ public class TextLayoutManager: NSObject { lineHeightMultiplier: lineHeightMultiplier, estimatedLineHeight: estimateLineHeight(), range: position.range, - stringRef: textStorage + stringRef: textStorage, + markedRanges: markedTextManager.markedRanges(in: position.range) ) if position.range.isEmpty { diff --git a/Sources/CodeEditInputView/TextLine/TextLine.swift b/Sources/CodeEditInputView/TextLine/TextLine.swift index b20c18744..c210d2821 100644 --- a/Sources/CodeEditInputView/TextLine/TextLine.swift +++ b/Sources/CodeEditInputView/TextLine/TextLine.swift @@ -41,20 +41,23 @@ public final class TextLine: Identifiable, Equatable { /// - estimatedLineHeight: The estimated height for an empty line. /// - range: The range this text range represents in the entire document. /// - stringRef: A reference to the string storage for the document. + /// - markedRanges: Any marked ranges in the line. func prepareForDisplay( maxWidth: CGFloat, lineHeightMultiplier: CGFloat, estimatedLineHeight: CGFloat, range: NSRange, - stringRef: NSTextStorage + stringRef: NSTextStorage, + markedRanges: MarkedTextManager.MarkedRanges? ) { let string = stringRef.attributedSubstring(from: range) self.maxWidth = maxWidth - typesetter.prepareToTypeset( + typesetter.typeset( string, maxWidth: maxWidth, lineHeightMultiplier: lineHeightMultiplier, - estimatedLineHeight: estimatedLineHeight + estimatedLineHeight: estimatedLineHeight, + markedRanges: markedRanges ) needsLayout = false } diff --git a/Sources/CodeEditInputView/TextLine/Typesetter.swift b/Sources/CodeEditInputView/TextLine/Typesetter.swift index bb8d76232..04d03594d 100644 --- a/Sources/CodeEditInputView/TextLine/Typesetter.swift +++ b/Sources/CodeEditInputView/TextLine/Typesetter.swift @@ -17,15 +17,24 @@ final class Typesetter { init() { } - func prepareToTypeset( + func typeset( _ string: NSAttributedString, maxWidth: CGFloat, lineHeightMultiplier: CGFloat, - estimatedLineHeight: CGFloat + estimatedLineHeight: CGFloat, + markedRanges: MarkedTextManager.MarkedRanges? ) { lineFragments.removeAll() - self.typesetter = CTTypesetterCreateWithAttributedString(string) - self.string = string + if let markedRanges { + let mutableString = NSMutableAttributedString(attributedString: string) + for markedRange in markedRanges.ranges { + mutableString.addAttributes(markedRanges.attributes, range: markedRange) + } + self.string = mutableString + } else { + self.string = string + } + self.typesetter = CTTypesetterCreateWithAttributedString(self.string) generateLines( maxWidth: maxWidth, lineHeightMultiplier: lineHeightMultiplier, diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift index 796c07fc3..f8aa90d8b 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift @@ -8,13 +8,15 @@ import Foundation extension TextSelectionManager { - public func willReplaceCharacters(in range: NSRange, replacementLength: Int) { + public func didReplaceCharacters(in range: NSRange, replacementLength: Int) { let delta = replacementLength == 0 ? -range.length : replacementLength for textSelection in self.textSelections { if textSelection.range.location > range.max { textSelection.range.location = max(0, textSelection.range.location + delta) textSelection.range.length = 0 - } else if textSelection.range.intersection(range) != nil || textSelection.range == range { + } else if textSelection.range.intersection(range) != nil + || textSelection.range == range + || (textSelection.range.isEmpty && textSelection.range.location == range.max) { if replacementLength > 0 { textSelection.range.location = range.location + replacementLength } else { diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift index 44f36b1fe..60b36ed3b 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift @@ -20,11 +20,6 @@ public protocol TextSelectionManagerDelegate: AnyObject { /// Draws selections using a draw method similar to the `TextLayoutManager` class, and adds cursor views when /// appropriate. public class TextSelectionManager: NSObject { - struct MarkedText { - let range: NSRange - let attributedString: NSAttributedString - } - // MARK: - TextSelection public class TextSelection: Hashable { @@ -84,7 +79,6 @@ public class TextSelectionManager: NSObject { public var selectedLineBackgroundColor: NSColor = NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) public var selectionBackgroundColor: NSColor = NSColor.selectedTextBackgroundColor - internal var markedText: [MarkedText] = [] internal(set) public var textSelections: [TextSelection] = [] internal weak var layoutManager: TextLayoutManager? internal weak var textStorage: NSTextStorage? diff --git a/Sources/CodeEditInputView/TextView/TextView+Mouse.swift b/Sources/CodeEditInputView/TextView/TextView+Mouse.swift index 43947de74..1ad463245 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Mouse.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Mouse.swift @@ -21,13 +21,16 @@ extension TextView { switch event.clickCount { case 1: if event.modifierFlags.intersection(.deviceIndependentFlagsMask).isSuperset(of: [.control, .shift]) { + unmarkText() selectionManager.addSelectedRange(NSRange(location: offset, length: 0)) } else { selectionManager.setSelectedRange(NSRange(location: offset, length: 0)) } case 2: + unmarkText() selectWord(nil) case 3: + unmarkText() selectLine(nil) default: break diff --git a/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift b/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift index 5c4ef131b..30149e415 100644 --- a/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift +++ b/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift @@ -28,31 +28,25 @@ import AppKit 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 } - - var insertString: String + /// Converts an `Any` to a valid string type if possible. + /// Throws an `assertionFailure` if not a valid type (`NSAttributedString`, `NSString`, or `String`) + private func anyToString(_ string: Any) -> String? { switch string { case let string as NSString: - insertString = string as String + return string as String case let string as NSAttributedString: - insertString = string.string + return string.string default: - insertString = "" assertionFailure("\(#function) called with invalid string type. Expected String or NSAttributedString.") + return nil } + } + /// Inserts the string at the replacement range. If replacement range is `NSNotFound`, uses the selection ranges. + private func _insertText(insertString: String, replacementRange: NSRange) { + var insertString = insertString if LineEnding(rawValue: insertString) == .carriageReturn - && layoutManager.detectedLineEnding == .carriageReturnLineFeed { + && layoutManager.detectedLineEnding == .carriageReturnLineFeed { insertString = LineEnding.carriageReturnLineFeed.rawValue } @@ -65,6 +59,26 @@ extension TextView: NSTextInputClient { selectionManager.textSelections.forEach { $0.suggestedXPos = nil } } + /// 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, let insertString = anyToString(string) else { return } + if layoutManager.markedTextManager.hasMarkedText { + _undoManager?.disable() + replaceCharacters(in: layoutManager.markedTextManager.markedRanges, with: "") + _undoManager?.enable() + layoutManager.markedTextManager.removeAll() + } + _insertText(insertString: insertString, replacementRange: replacementRange) + } + override public func insertText(_ insertString: Any) { insertText(insertString, replacementRange: NSRange(location: NSNotFound, length: 0)) } @@ -84,7 +98,18 @@ extension TextView: NSTextInputClient { /// - 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) + guard isEditable, let insertString = anyToString(string) else { return } + // Needs to insert text, but not notify the undo manager. The undo/redo actions will handle + // removing marked text. + _undoManager?.disable() + layoutManager.markedTextManager.updateMarkedRanges( + insertLength: (insertString as NSString).length, + replacementRange: replacementRange, + selectedRange: selectedRange, + textSelections: selectionManager.textSelections + ) + _insertText(insertString: insertString, replacementRange: replacementRange) + _undoManager?.enable() } /// Unmarks the marked text. @@ -93,7 +118,9 @@ extension TextView: NSTextInputClient { /// 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() + print(#function) + layoutManager.markedTextManager.removeAll() + layoutManager.setNeedsLayout() } /// Returns the range of selected text. @@ -111,8 +138,7 @@ extension TextView: NSTextInputClient { /// /// - Returns: The range of marked text or {NSNotFound, 0} if there is no marked range. @objc public func markedRange() -> NSRange { - // TODO: markedRange() - return NSRange(location: NSNotFound, length: 0) + return layoutManager?.markedTextManager.markedRanges.first ?? NSRange(location: NSNotFound, length: 0) } /// Returns a Boolean value indicating whether the receiver has marked text. @@ -122,8 +148,7 @@ extension TextView: NSTextInputClient { /// /// - Returns: true if the receiver has marked text; otherwise false. @objc public func hasMarkedText() -> Bool { - // TODO: hasMarkedText() - return false + return layoutManager.markedTextManager.hasMarkedText } /// Returns an array of attribute names recognized by the receiver. @@ -133,7 +158,7 @@ extension TextView: NSTextInputClient { /// /// - Returns: An array of NSString objects representing names for the supported attributes. @objc public func validAttributesForMarkedText() -> [NSAttributedString.Key] { - [.underlineStyle, .underlineColor] + [.underlineStyle, .underlineColor, .backgroundColor, .font, .foregroundColor] } // MARK: - Contents diff --git a/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift b/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift index a043271db..4e4b662d1 100644 --- a/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift +++ b/Sources/CodeEditInputView/TextView/TextView+ReplaceCharacters.swift @@ -34,7 +34,7 @@ extension TextView { in: range, with: NSAttributedString(string: string, attributes: typingAttributes) ) - selectionManager.willReplaceCharacters(in: range, replacementLength: (string as NSString).length) + selectionManager.didReplaceCharacters(in: range, replacementLength: (string as NSString).length) delegate?.textView(self, didReplaceContentsIn: range, with: string) } diff --git a/Sources/CodeEditInputView/TextView/TextView.swift b/Sources/CodeEditInputView/TextView/TextView.swift index 819a773ce..f0cf5bda7 100644 --- a/Sources/CodeEditInputView/TextView/TextView.swift +++ b/Sources/CodeEditInputView/TextView/TextView.swift @@ -21,6 +21,7 @@ import TextStory /// | | |-> Typesetter Lays out and calculates line fragments /// | | |-> [LineFragment] Represents a visual text line, stored in a line storage for long lines /// | |-> [LineFragmentView] Reusable line fragment view that draws a line fragment. +/// | |-> MarkedRangeManager Manages marked ranges, updates layout if needed to accomodate. /// | /// |-> TextSelectionManager Maintains, modifies, and renders text selections /// | |-> [TextSelection] @@ -275,7 +276,6 @@ public class TextView: NSView, NSTextContent { selectionManager.textStorage = textStorage selectionManager.textSelections.removeAll() - selectionManager.markedText.removeAll() _undoManager?.clearStack() diff --git a/Sources/CodeEditInputView/Utils/CEUndoManager.swift b/Sources/CodeEditInputView/Utils/CEUndoManager.swift index b9dbe1185..039a70f7e 100644 --- a/Sources/CodeEditInputView/Utils/CEUndoManager.swift +++ b/Sources/CodeEditInputView/Utils/CEUndoManager.swift @@ -56,8 +56,8 @@ public class CEUndoManager { } public let manager: DelegatedUndoManager - public var isUndoing: Bool = false - public var isRedoing: Bool = false + private(set) public var isUndoing: Bool = false + private(set) public var isRedoing: Bool = false public var canUndo: Bool { !undoStack.isEmpty @@ -72,7 +72,11 @@ public class CEUndoManager { private var redoStack: [UndoGroup] = [] private weak var textView: TextView? - private(set) var isGrouping: Bool = false + private(set) public var isGrouping: Bool = false + /// True when the manager is ignoring mutations. + private var isDisabled: Bool = false + + // MARK: - Init public init(textView: TextView) { self.textView = textView @@ -80,9 +84,11 @@ public class CEUndoManager { manager.parent = self } + // MARK: - Undo/Redo + /// Performs an undo operation if there is one available. public func undo() { - guard let item = undoStack.popLast(), let textView else { + guard !isDisabled, let item = undoStack.popLast(), let textView else { return } isUndoing = true @@ -97,7 +103,7 @@ public class CEUndoManager { /// Performs a redo operation if there is one available. public func redo() { - guard let item = redoStack.popLast(), let textView else { + guard !isDisabled, let item = redoStack.popLast(), let textView else { return } isRedoing = true @@ -116,6 +122,8 @@ public class CEUndoManager { redoStack.removeAll() } + // MARK: - Mutations + /// Registers a mutation into the undo stack. /// /// Calling this method while the manager is in an undo/redo operation will result in a no-op. @@ -143,6 +151,8 @@ public class CEUndoManager { redoStack.removeAll() } + // MARK: - Grouping + /// Groups all incoming mutations. public func beginGrouping() { isGrouping = true @@ -196,4 +206,18 @@ public class CEUndoManager { ) } } + + // MARK: - Disable + + /// Sets the undo manager to ignore incoming mutations until the matching `enable` method is called. + /// Cannot be nested. + public func disable() { + isDisabled = true + } + + /// Sets the undo manager to begin receiving incoming mutations after a call to `disable` + /// Cannot be nested. + public func enable() { + isDisabled = false + } } diff --git a/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift b/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift index 2c7ab0996..a1e5cb800 100644 --- a/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift +++ b/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift @@ -36,7 +36,7 @@ extension TextView: TextInterface { /// - Parameter mutation: The mutation to apply. public func applyMutation(_ mutation: TextMutation) { layoutManager.willReplaceCharactersInRange(range: mutation.range, with: mutation.string) - selectionManager.willReplaceCharacters( + selectionManager.didReplaceCharacters( in: mutation.range, replacementLength: (mutation.string as NSString).length ) diff --git a/Tests/CodeEditInputViewTests/TextSelectionManagerTests.swift b/Tests/CodeEditInputViewTests/TextSelectionManagerTests.swift index 4a1d4359a..c77358a0e 100644 --- a/Tests/CodeEditInputViewTests/TextSelectionManagerTests.swift +++ b/Tests/CodeEditInputViewTests/TextSelectionManagerTests.swift @@ -9,7 +9,6 @@ final class TextSelectionManagerTests: XCTestCase { textStorage = NSTextStorage(string: "Loren Ipsum 💯") layoutManager = TextLayoutManager( textStorage: textStorage, - typingAttributes: [:], lineHeightMultiplier: 1.0, wrapLines: false, textView: NSView(), From 4716991d533c83bad7282e47e02f841079fc6554 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Wed, 8 Nov 2023 16:25:08 -0600 Subject: [PATCH 67/75] Update README.md Icon --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b0f4e3fa3..e447d0307 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- +

CodeEditTextView

From 05390c5235854ebfe03597088207f61b0891efa8 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Wed, 8 Nov 2023 16:30:13 -0600 Subject: [PATCH 68/75] Update README.md --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index e447d0307..bdf25c7b6 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,10 @@ See this issue https://github.com/CodeEditApp/CodeEditLanguages/issues/10 on `Co ## Dependencies -Special thanks to both [Marcin Krzyzanowski](https://twitter.com/krzyzanowskim) & [Matt Massicotte](https://twitter.com/mattie) for the great work they've done! +Special thanks to [Matt Massicotte](https://twitter.com/mattie) for the great work he's done! | Package | Source | Author | | :- | :- | :- | -| `STTextView` | [GitHub](https://github.com/krzyzanowskim/STTextView) | [Marcin Krzyzanowski](https://twitter.com/krzyzanowskim) | | `SwiftTreeSitter` | [GitHub](https://github.com/ChimeHQ/SwiftTreeSitter) | [Matt Massicotte](https://twitter.com/mattie) | ## License From 93bfd7606a0193ef929cc9509e145f44632594f9 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 8 Nov 2023 18:19:00 -0600 Subject: [PATCH 69/75] Marked Text, Remove Common Module, `lineBreakStrategy` --- Package.resolved | 17 +- Package.swift | 24 ++- .../Extensions/NSRange+isEmpty.swift | 0 .../Extensions/NSTextStorage+getLine.swift | 0 .../Extensions/PixelAligned.swift | 0 .../MarkedTextManager/MarkedTextManager.swift | 31 +++- .../TextLayoutManager+Edits.swift | 1 - .../TextLayoutManager+Public.swift | 10 +- .../TextLayoutManager/TextLayoutManager.swift | 46 ++++-- .../TextLine/LineBreakStrategy.swift | 3 + .../TextLine/LineFragmentView.swift | 1 - .../CodeEditInputView/TextLine/TextLine.swift | 26 ++-- .../TextLine/Typesetter.swift | 21 ++- .../TextLineStorage+NSTextStorage.swift | 1 - .../TextSelectionManager+Move.swift | 1 - ...lectionManager+SelectionManipulation.swift | 6 +- .../TextSelectionManager.swift | 3 - .../TextView/TextView+Delete.swift | 1 + .../TextView/TextView+Drag.swift | 2 +- .../TextView/TextView+Mouse.swift | 2 +- .../TextView/TextView+Move.swift | 53 ++++--- .../TextView/TextView+NSTextInput.swift | 37 +++-- .../TextView/TextView+Select.swift | 3 + .../TextView/TextView+Setup.swift | 4 +- .../TextView+TextLayoutManagerDelegate.swift | 2 +- .../CodeEditInputView/TextView/TextView.swift | 15 +- .../Utils/CEUndoManager.swift | 2 +- .../Utils}/MultiStorageDelegate.swift | 0 .../Utils}/ViewReuseQueue.swift | 0 .../Controller/TextViewController.swift | 1 - .../CodeEditTextView/Gutter/GutterView.swift | 1 - .../TextSelectionManagerTests.swift | 147 ++++++++++++++++-- 32 files changed, 338 insertions(+), 123 deletions(-) rename Sources/{Common => CodeEditInputView}/Extensions/NSRange+isEmpty.swift (100%) rename Sources/{Common => CodeEditInputView}/Extensions/NSTextStorage+getLine.swift (100%) rename Sources/{Common => CodeEditInputView}/Extensions/PixelAligned.swift (100%) rename Sources/{Common => CodeEditInputView/Utils}/MultiStorageDelegate.swift (100%) rename Sources/{Common => CodeEditInputView/Utils}/ViewReuseQueue.swift (100%) diff --git a/Package.resolved b/Package.resolved index 9dd636e30..a361544d2 100644 --- a/Package.resolved +++ b/Package.resolved @@ -9,6 +9,15 @@ "version" : "0.1.17" } }, + { + "identity" : "mainoffender", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattmassicotte/MainOffender", + "state" : { + "revision" : "343cc3797618c29b48b037b4e2beea0664e75315", + "version" : "0.1.0" + } + }, { "identity" : "rearrange", "kind" : "remoteSourceControl", @@ -50,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/TextFormation", "state" : { - "revision" : "158a603054ed5176f18d7c08ba355c0e05cb0586", - "version" : "0.7.0" + "revision" : "b4987856bc860643ac2c9cdbc7d5f3e9ade68377", + "version" : "0.8.1" } }, { @@ -59,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/TextStory", "state" : { - "revision" : "b7b3fc551bd0177c32b3dc46d0478e9f0b6f8c6f", - "version" : "0.7.2" + "revision" : "8883fa739aa213e70e6cb109bfbf0a0b551e4cb5", + "version" : "0.8.0" } } ], diff --git a/Package.swift b/Package.swift index 9e6b9f35e..1f14ed258 100644 --- a/Package.swift +++ b/Package.swift @@ -29,10 +29,15 @@ let package = Package( url: "https://github.com/lukepistrol/SwiftLintPlugin", from: "0.2.2" ), - // Filters for indentation, pair completion, whitespace + // Text mutation, storage helpers + .package( + url: "https://github.com/ChimeHQ/TextStory", + from: "0.8.0" + ), + // Rules for indentation, pair completion, whitespace .package( url: "https://github.com/ChimeHQ/TextFormation", - from: "0.7.0" + from: "0.8.1" ), // Useful data structures .package( @@ -45,7 +50,6 @@ let package = Package( .target( name: "CodeEditTextView", dependencies: [ - "Common", "CodeEditInputView", "CodeEditLanguages", "TextFormation", @@ -59,18 +63,8 @@ let package = Package( .target( name: "CodeEditInputView", dependencies: [ - "Common", - "TextFormation" - ], - plugins: [ - .plugin(name: "SwiftLint", package: "SwiftLintPlugin") - ] - ), - - // Common classes and extensions used in both CodeEditTextView and CodeEditInputView - .target( - name: "Common", - dependencies: [ + "TextStory", + "TextFormation", .product(name: "Collections", package: "swift-collections") ], plugins: [ diff --git a/Sources/Common/Extensions/NSRange+isEmpty.swift b/Sources/CodeEditInputView/Extensions/NSRange+isEmpty.swift similarity index 100% rename from Sources/Common/Extensions/NSRange+isEmpty.swift rename to Sources/CodeEditInputView/Extensions/NSRange+isEmpty.swift diff --git a/Sources/Common/Extensions/NSTextStorage+getLine.swift b/Sources/CodeEditInputView/Extensions/NSTextStorage+getLine.swift similarity index 100% rename from Sources/Common/Extensions/NSTextStorage+getLine.swift rename to Sources/CodeEditInputView/Extensions/NSTextStorage+getLine.swift diff --git a/Sources/Common/Extensions/PixelAligned.swift b/Sources/CodeEditInputView/Extensions/PixelAligned.swift similarity index 100% rename from Sources/Common/Extensions/PixelAligned.swift rename to Sources/CodeEditInputView/Extensions/PixelAligned.swift diff --git a/Sources/CodeEditInputView/MarkedTextManager/MarkedTextManager.swift b/Sources/CodeEditInputView/MarkedTextManager/MarkedTextManager.swift index 0a79509d8..09be2d6a7 100644 --- a/Sources/CodeEditInputView/MarkedTextManager/MarkedTextManager.swift +++ b/Sources/CodeEditInputView/MarkedTextManager/MarkedTextManager.swift @@ -29,7 +29,7 @@ class MarkedTextManager { func removeAll() { markedRanges.removeAll() } - + /// Updates the stored marked ranges. /// - Parameters: /// - insertLength: The length of the string being inserted. @@ -50,7 +50,7 @@ class MarkedTextManager { markedRanges = [selectedRange] } } - + /// Finds any marked ranges for a line and returns them. /// - Parameter lineRange: The range of the line. /// - Returns: A `MarkedRange` struct with information about attributes and ranges. `nil` if there is no marked @@ -68,4 +68,31 @@ class MarkedTextManager { return MarkedRanges(ranges: ranges, attributes: attributes) } } + + /// Updates marked text ranges for a new set of selections. + /// - Parameter textSelections: The new text selections. + /// - Returns: `True` if the marked text needs layout. + func updateForNewSelections(textSelections: [TextSelectionManager.TextSelection]) -> Bool { + // Ensure every marked range has a matching selection. + // If any marked ranges do not have a matching selection, unmark. + // Matching, in this context, means having a selection in the range location...max + var markedRanges = markedRanges + for textSelection in textSelections { + if let markedRangeIdx = markedRanges.firstIndex(where: { + ($0.location...$0.max).contains(textSelection.range.location) + && ($0.location...$0.max).contains(textSelection.range.max) + }) { + markedRanges.remove(at: markedRangeIdx) + } else { + return true + } + } + + // If any remaining marked ranges, we need to unmark. + if !markedRanges.isEmpty { + return false + } else { + return true + } + } } diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift index 0068eb047..0b00d2c52 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift @@ -6,7 +6,6 @@ // import AppKit -import Common // MARK: - Edits diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift index 8e1fc5c79..97af1d1ed 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift @@ -188,13 +188,17 @@ extension TextLayoutManager { /// - Parameter offset: The offset to ensure layout until. private func ensureLayoutFor(position: TextLineStorage.TextLinePosition) -> CGFloat { guard let textStorage else { return 0 } - position.data.prepareForDisplay( + let displayData = TextLine.DisplayData( maxWidth: maxLineLayoutWidth, lineHeightMultiplier: lineHeightMultiplier, - estimatedLineHeight: estimateLineHeight(), + estimatedLineHeight: estimateLineHeight() + ) + position.data.prepareForDisplay( + displayData: displayData, range: position.range, stringRef: textStorage, - markedRanges: markedTextManager.markedRanges(in: position.range) + markedRanges: markedTextManager.markedRanges(in: position.range), + breakStrategy: lineBreakStrategy ) var height: CGFloat = 0 for fragmentPosition in position.data.lineFragments { diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift index 04a697c32..eeaa351d0 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift @@ -7,7 +7,6 @@ import Foundation import AppKit -import Common public protocol TextLayoutManagerDelegate: AnyObject { func layoutManagerHeightDidUpdate(newHeight: CGFloat) @@ -48,10 +47,17 @@ public class TextLayoutManager: NSObject { lineStorage.count } + /// The strategy to use when breaking lines. Defaults to ``LineBreakStrategy/word``. + public var lineBreakStrategy: LineBreakStrategy = .word { + didSet { + setNeedsLayout() + } + } + // MARK: - Internal - internal weak var textStorage: NSTextStorage? - internal var lineStorage: TextLineStorage = TextLineStorage() + weak var textStorage: NSTextStorage? + var lineStorage: TextLineStorage = TextLineStorage() var markedTextManager: MarkedTextManager = MarkedTextManager() private let viewReuseQueue: ViewReuseQueue = ViewReuseQueue() private var visibleLineIds: Set = [] @@ -63,7 +69,7 @@ public class TextLayoutManager: NSObject { transactionCounter > 0 } - weak internal var layoutView: NSView? + weak var layoutView: NSView? /// The calculated maximum width of all laid out lines. /// - Note: This does not indicate *the* maximum width of the text view if all lines have not been laid out. @@ -79,6 +85,13 @@ public class TextLayoutManager: NSObject { : .greatestFiniteMagnitude } + /// Contains all data required to perform layout on a text line. + private struct LineLayoutData { + let minY: CGFloat + let maxY: CGFloat + let maxWidth: CGFloat + } + // MARK: - Init /// Initialize a text layout manager and prepare it for use. @@ -124,7 +137,7 @@ public class TextLayoutManager: NSObject { print("Text Layout Manager built in: ", TimeInterval(nanos) / TimeInterval(NSEC_PER_MSEC), "ms") #endif } - + /// Resets the layout manager to an initial state. internal func reset() { lineStorage.removeAll() @@ -235,9 +248,7 @@ public class TextLayoutManager: NSObject { let lineSize = layoutLine( linePosition, textStorage: textStorage, - minY: linePosition.yPos, - maxY: maxY, - maxWidth: maxLineLayoutWidth, + layoutData: LineLayoutData(minY: linePosition.yPos, maxY: maxY, maxWidth: maxLineLayoutWidth), laidOutFragmentIDs: &usedFragmentIDs ) if lineSize.height != linePosition.height { @@ -297,19 +308,22 @@ public class TextLayoutManager: NSObject { private func layoutLine( _ position: TextLineStorage.TextLinePosition, textStorage: NSTextStorage, - minY: CGFloat, - maxY: CGFloat, - maxWidth: CGFloat, + layoutData: LineLayoutData, laidOutFragmentIDs: inout Set ) -> CGSize { + let lineDisplayData = TextLine.DisplayData( + maxWidth: layoutData.maxWidth, + lineHeightMultiplier: lineHeightMultiplier, + estimatedLineHeight: estimateLineHeight() + ) + let line = position.data line.prepareForDisplay( - maxWidth: maxWidth, - lineHeightMultiplier: lineHeightMultiplier, - estimatedLineHeight: estimateLineHeight(), + displayData: lineDisplayData, range: position.range, stringRef: textStorage, - markedRanges: markedTextManager.markedRanges(in: position.range) + markedRanges: markedTextManager.markedRanges(in: position.range), + breakStrategy: lineBreakStrategy ) if position.range.isEmpty { @@ -323,7 +337,7 @@ public class TextLayoutManager: NSObject { for lineFragmentPosition in line.typesetter.lineFragments { let lineFragment = lineFragmentPosition.data - layoutFragmentView(for: lineFragmentPosition, at: minY + lineFragmentPosition.yPos) + layoutFragmentView(for: lineFragmentPosition, at: layoutData.minY + lineFragmentPosition.yPos) width = max(width, lineFragment.width) height += lineFragment.scaledHeight diff --git a/Sources/CodeEditInputView/TextLine/LineBreakStrategy.swift b/Sources/CodeEditInputView/TextLine/LineBreakStrategy.swift index 865edd92a..d64e0d93f 100644 --- a/Sources/CodeEditInputView/TextLine/LineBreakStrategy.swift +++ b/Sources/CodeEditInputView/TextLine/LineBreakStrategy.swift @@ -5,7 +5,10 @@ // Created by Khan Winter on 9/19/23. // +/// Options for breaking lines when they cannot fit in the viewport. public enum LineBreakStrategy { + /// Break lines at word boundaries when possible. case word + /// Break lines at the nearest character, regardless of grouping. case character } diff --git a/Sources/CodeEditInputView/TextLine/LineFragmentView.swift b/Sources/CodeEditInputView/TextLine/LineFragmentView.swift index 1f5bff496..3d95eea6c 100644 --- a/Sources/CodeEditInputView/TextLine/LineFragmentView.swift +++ b/Sources/CodeEditInputView/TextLine/LineFragmentView.swift @@ -6,7 +6,6 @@ // import AppKit -import Common /// Displays a line fragment. final class LineFragmentView: NSView { diff --git a/Sources/CodeEditInputView/TextLine/TextLine.swift b/Sources/CodeEditInputView/TextLine/TextLine.swift index c210d2821..977b9741d 100644 --- a/Sources/CodeEditInputView/TextLine/TextLine.swift +++ b/Sources/CodeEditInputView/TextLine/TextLine.swift @@ -36,27 +36,24 @@ public final class TextLine: Identifiable, Equatable { /// Prepares the line for display, generating all potential line breaks and calculating the real height of the line. /// - Parameters: - /// - maxWidth: The maximum width the line can be. Used to find line breaks. - /// - lineHeightMultiplier: The multiplier to use for lines. - /// - estimatedLineHeight: The estimated height for an empty line. + /// - displayData: Information required to display a text line. /// - range: The range this text range represents in the entire document. /// - stringRef: A reference to the string storage for the document. /// - markedRanges: Any marked ranges in the line. + /// - breakStrategy: Determines how line breaks are calculated. func prepareForDisplay( - maxWidth: CGFloat, - lineHeightMultiplier: CGFloat, - estimatedLineHeight: CGFloat, + displayData: DisplayData, range: NSRange, stringRef: NSTextStorage, - markedRanges: MarkedTextManager.MarkedRanges? + markedRanges: MarkedTextManager.MarkedRanges?, + breakStrategy: LineBreakStrategy ) { let string = stringRef.attributedSubstring(from: range) - self.maxWidth = maxWidth + self.maxWidth = displayData.maxWidth typesetter.typeset( string, - maxWidth: maxWidth, - lineHeightMultiplier: lineHeightMultiplier, - estimatedLineHeight: estimatedLineHeight, + displayData: displayData, + breakStrategy: breakStrategy, markedRanges: markedRanges ) needsLayout = false @@ -65,4 +62,11 @@ public final class TextLine: Identifiable, Equatable { public static func == (lhs: TextLine, rhs: TextLine) -> Bool { lhs.id == rhs.id } + + /// Contains all required data to perform a typeset and layout operation on a text line. + struct DisplayData { + let maxWidth: CGFloat + let lineHeightMultiplier: CGFloat + let estimatedLineHeight: CGFloat + } } diff --git a/Sources/CodeEditInputView/TextLine/Typesetter.swift b/Sources/CodeEditInputView/TextLine/Typesetter.swift index 04d03594d..4f46965ba 100644 --- a/Sources/CodeEditInputView/TextLine/Typesetter.swift +++ b/Sources/CodeEditInputView/TextLine/Typesetter.swift @@ -19,9 +19,8 @@ final class Typesetter { func typeset( _ string: NSAttributedString, - maxWidth: CGFloat, - lineHeightMultiplier: CGFloat, - estimatedLineHeight: CGFloat, + displayData: TextLine.DisplayData, + breakStrategy: LineBreakStrategy, markedRanges: MarkedTextManager.MarkedRanges? ) { lineFragments.removeAll() @@ -36,9 +35,10 @@ final class Typesetter { } self.typesetter = CTTypesetterCreateWithAttributedString(self.string) generateLines( - maxWidth: maxWidth, - lineHeightMultiplier: lineHeightMultiplier, - estimatedLineHeight: estimatedLineHeight + maxWidth: displayData.maxWidth, + lineHeightMultiplier: displayData.lineHeightMultiplier, + estimatedLineHeight: displayData.estimatedLineHeight, + breakStrategy: breakStrategy ) } @@ -49,7 +49,12 @@ final class Typesetter { /// - maxWidth: The maximum width the line can be. /// - lineHeightMultiplier: The multiplier to apply to an empty line's height. /// - estimatedLineHeight: The estimated height of an empty line. - private func generateLines(maxWidth: CGFloat, lineHeightMultiplier: CGFloat, estimatedLineHeight: CGFloat) { + private func generateLines( + maxWidth: CGFloat, + lineHeightMultiplier: CGFloat, + estimatedLineHeight: CGFloat, + breakStrategy: LineBreakStrategy + ) { guard let typesetter else { return } var lines: [TextLineStorage.BuildItem] = [] var height: CGFloat = 0 @@ -69,7 +74,7 @@ final class Typesetter { while startIndex < string.length { let lineBreak = suggestLineBreak( using: typesetter, - strategy: .word, // TODO: Make this configurable + strategy: breakStrategy, startingOffset: startIndex, constrainingWidth: maxWidth ) diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift index b5e6d10d4..9848ffaa8 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift @@ -6,7 +6,6 @@ // import AppKit -import Common extension TextLineStorage where Data == TextLine { /// Builds the line storage object from the given `NSTextStorage`. diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Move.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Move.swift index fa912970d..e917f1730 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Move.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Move.swift @@ -6,7 +6,6 @@ // import AppKit -import Common extension TextSelectionManager { /// Moves all selections, determined by the direction and destination provided. diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift index c30b3c727..d8c2c9ee6 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift @@ -223,11 +223,12 @@ public extension TextSelectionManager { /// - 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) else { + guard let line = layoutManager?.textLineForOffset(offset), + let lineText = textStorage?.substring(from: line.range) else { return NSRange(location: offset, length: 0) } let lineBound = delta > 0 - ? line.range.max - (layoutManager?.detectedLineEnding.length ?? 1) + ? line.range.max - (LineEnding(line: lineText)?.length ?? 0) : line.range.location return _extendSelectionLine(string: string, lineBound: lineBound, offset: offset, delta: delta) @@ -383,7 +384,6 @@ public extension TextSelectionManager { /// - 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 { - // TODO: Needs to force layout for the rect being moved by. guard let layoutView, let endOffset = layoutManager?.textOffsetAtPoint( CGPoint( x: delta > 0 ? layoutView.frame.maxX : layoutView.frame.minX, diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift index 60b36ed3b..36fa0456a 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift @@ -6,7 +6,6 @@ // import AppKit -import Common public protocol TextSelectionManagerDelegate: AnyObject { var visibleTextRange: NSRange? { get } @@ -246,8 +245,6 @@ public class TextSelectionManager: NSObject { /// - range: The range to highlight. /// - context: The context to draw in. private func drawSelectedRange(in rect: NSRect, for textSelection: TextSelection, context: CGContext) { - guard let layoutManager else { return } - let range = textSelection.range context.saveGState() context.setFillColor(selectionBackgroundColor.cgColor) diff --git a/Sources/CodeEditInputView/TextView/TextView+Delete.swift b/Sources/CodeEditInputView/TextView/TextView+Delete.swift index 71b4fb140..6804a2375 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Delete.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Delete.swift @@ -60,5 +60,6 @@ extension TextView { textSelection.range.formUnion(extendedRange) } replaceCharacters(in: selectionManager.textSelections.map(\.range), with: "") + unmarkTextIfNeeded() } } diff --git a/Sources/CodeEditInputView/TextView/TextView+Drag.swift b/Sources/CodeEditInputView/TextView/TextView+Drag.swift index 3841cfd60..ab6c4d5d1 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Drag.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Drag.swift @@ -38,7 +38,7 @@ extension TextView: NSDraggingSource { selectionManager.getFillRects(in: frame, for: $0) } // TODO: This SUcks - let minX = (selectionRects.min(by: { $0.minX < $1.minX })?.minX ?? 0.0) + let minX = selectionRects.min(by: { $0.minX < $1.minX })?.minX ?? 0.0 let minY = selectionRects.min(by: { $0.minY < $1.minY })?.minY ?? 0.0 let maxX = selectionRects.max(by: { $0.maxX < $1.maxX })?.maxX ?? 0.0 let maxY = selectionRects.max(by: { $0.maxY < $1.maxY })?.maxY ?? 0.0 diff --git a/Sources/CodeEditInputView/TextView/TextView+Mouse.swift b/Sources/CodeEditInputView/TextView/TextView+Mouse.swift index 1ad463245..3b4300120 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Mouse.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Mouse.swift @@ -6,7 +6,6 @@ // import AppKit -import Common extension TextView { override public func mouseDown(with event: NSEvent) { @@ -25,6 +24,7 @@ extension TextView { selectionManager.addSelectedRange(NSRange(location: offset, length: 0)) } else { selectionManager.setSelectedRange(NSRange(location: offset, length: 0)) + unmarkTextIfNeeded() } case 2: unmarkText() diff --git a/Sources/CodeEditInputView/TextView/TextView+Move.swift b/Sources/CodeEditInputView/TextView/TextView+Move.swift index 0af505636..eed1f9ab3 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Move.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Move.swift @@ -8,149 +8,154 @@ import Foundation extension TextView { + fileprivate func updateAfterMove() { + unmarkTextIfNeeded() + scrollSelectionToVisible() + } + /// Moves the cursors up one character. override public func moveUp(_ sender: Any?) { selectionManager.moveSelections(direction: .up, destination: .character) - scrollSelectionToVisible() + updateAfterMove() } /// Moves the cursors up one character extending the current selection. override public func moveUpAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .up, destination: .character, modifySelection: true) - scrollSelectionToVisible() + updateAfterMove() } /// Moves the cursors down one character. override public func moveDown(_ sender: Any?) { selectionManager.moveSelections(direction: .down, destination: .character) - scrollSelectionToVisible() + updateAfterMove() } /// Moves the cursors down one character extending the current selection. override public func moveDownAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .down, destination: .character, modifySelection: true) - scrollSelectionToVisible() + updateAfterMove() } /// Moves the cursors left one character. override public func moveLeft(_ sender: Any?) { selectionManager.moveSelections(direction: .backward, destination: .character) - scrollSelectionToVisible() + updateAfterMove() } /// Moves the cursors left one character extending the current selection. override public func moveLeftAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .backward, destination: .character, modifySelection: true) - scrollSelectionToVisible() + updateAfterMove() } /// Moves the cursors right one character. override public func moveRight(_ sender: Any?) { selectionManager.moveSelections(direction: .forward, destination: .character) - scrollSelectionToVisible() + updateAfterMove() } /// Moves the cursors right one character extending the current selection. override public func moveRightAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .forward, destination: .character, modifySelection: true) - scrollSelectionToVisible() + updateAfterMove() } /// Moves the cursors left one word. override public func moveWordLeft(_ sender: Any?) { selectionManager.moveSelections(direction: .backward, destination: .word) - scrollSelectionToVisible() + updateAfterMove() } /// Moves the cursors left one word extending the current selection. override public func moveWordLeftAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .backward, destination: .word, modifySelection: true) - scrollSelectionToVisible() + updateAfterMove() } /// Moves the cursors right one word. override public func moveWordRight(_ sender: Any?) { selectionManager.moveSelections(direction: .forward, destination: .word) - scrollSelectionToVisible() + updateAfterMove() } /// Moves the cursors right one word extending the current selection. override public func moveWordRightAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .forward, destination: .word, modifySelection: true) - scrollSelectionToVisible() + updateAfterMove() } /// Moves the cursors left to the end of the line. override public func moveToLeftEndOfLine(_ sender: Any?) { selectionManager.moveSelections(direction: .backward, destination: .visualLine) - scrollSelectionToVisible() + updateAfterMove() } /// Moves the cursors left to the end of the line extending the current selection. override public func moveToLeftEndOfLineAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .backward, destination: .visualLine, modifySelection: true) - scrollSelectionToVisible() + updateAfterMove() } /// Moves the cursors right to the end of the line. override public func moveToRightEndOfLine(_ sender: Any?) { selectionManager.moveSelections(direction: .forward, destination: .visualLine) - scrollSelectionToVisible() + updateAfterMove() } /// Moves the cursors right to the end of the line extending the current selection. override public func moveToRightEndOfLineAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .forward, destination: .visualLine, modifySelection: true) - scrollSelectionToVisible() + updateAfterMove() } /// Moves the cursors to the beginning of the line, if pressed again selects the next line up. override public func moveToBeginningOfParagraph(_ sender: Any?) { selectionManager.moveSelections(direction: .up, destination: .line) - scrollSelectionToVisible() + updateAfterMove() } /// Moves the cursors to the beginning of the line, if pressed again selects the next line up extending the current /// selection. override public func moveToBeginningOfParagraphAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .up, destination: .line, modifySelection: true) - scrollSelectionToVisible() + updateAfterMove() } /// Moves the cursors to the end of the line, if pressed again selects the next line up. override public func moveToEndOfParagraph(_ sender: Any?) { selectionManager.moveSelections(direction: .down, destination: .line) - scrollSelectionToVisible() + updateAfterMove() } /// Moves the cursors to the end of the line, if pressed again selects the next line up extending the current /// selection. override public func moveToEndOfParagraphAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .down, destination: .line, modifySelection: true) - scrollSelectionToVisible() + updateAfterMove() } /// Moves the cursors to the beginning of the document. override public func moveToBeginningOfDocument(_ sender: Any?) { selectionManager.moveSelections(direction: .up, destination: .document) - scrollSelectionToVisible() + updateAfterMove() } /// Moves the cursors to the beginning of the document extending the current selection. override public func moveToBeginningOfDocumentAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .up, destination: .document, modifySelection: true) - scrollSelectionToVisible() + updateAfterMove() } /// Moves the cursors to the end of the document. override public func moveToEndOfDocument(_ sender: Any?) { selectionManager.moveSelections(direction: .down, destination: .document) - scrollSelectionToVisible() + updateAfterMove() } /// Moves the cursors to the end of the document extending the current selection. override public func moveToEndOfDocumentAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .down, destination: .document, modifySelection: true) - scrollSelectionToVisible() + updateAfterMove() } } diff --git a/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift b/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift index 30149e415..a9274f329 100644 --- a/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift +++ b/Sources/CodeEditInputView/TextView/TextView+NSTextInput.swift @@ -70,12 +70,7 @@ 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, let insertString = anyToString(string) else { return } - if layoutManager.markedTextManager.hasMarkedText { - _undoManager?.disable() - replaceCharacters(in: layoutManager.markedTextManager.markedRanges, with: "") - _undoManager?.enable() - layoutManager.markedTextManager.removeAll() - } + unmarkText() _insertText(insertString: insertString, replacementRange: replacementRange) } @@ -99,8 +94,7 @@ extension TextView: NSTextInputClient { /// - replacementRange: The range to replace, computed from the beginning of the marked text. @objc public func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { guard isEditable, let insertString = anyToString(string) else { return } - // Needs to insert text, but not notify the undo manager. The undo/redo actions will handle - // removing marked text. + // Needs to insert text, but not notify the undo manager. _undoManager?.disable() layoutManager.markedTextManager.updateMarkedRanges( insertLength: (insertString as NSString).length, @@ -112,15 +106,36 @@ extension TextView: NSTextInputClient { _undoManager?.enable() } + /// Unmarks text and causes layout if needed after a selection update. + func unmarkTextIfNeeded() { + guard layoutManager.markedTextManager.hasMarkedText, + layoutManager.markedTextManager.updateForNewSelections( + textSelections: selectionManager.textSelections + ) else { + return + } + + layoutManager.markedTextManager.removeAll() + layoutManager.setNeedsLayout() + needsLayout = true + inputContext?.discardMarkedText() + } + /// 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() { - print(#function) - layoutManager.markedTextManager.removeAll() - layoutManager.setNeedsLayout() + if layoutManager.markedTextManager.hasMarkedText { + _undoManager?.disable() + replaceCharacters(in: layoutManager.markedTextManager.markedRanges, with: "") + _undoManager?.enable() + layoutManager.markedTextManager.removeAll() + layoutManager.setNeedsLayout() + needsLayout = true + inputContext?.discardMarkedText() + } } /// Returns the range of selected text. diff --git a/Sources/CodeEditInputView/TextView/TextView+Select.swift b/Sources/CodeEditInputView/TextView/TextView+Select.swift index e2d8e8381..bff72c237 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Select.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Select.swift @@ -11,6 +11,7 @@ import TextStory extension TextView { override public func selectAll(_ sender: Any?) { selectionManager.setSelectedRange(documentRange) + unmarkTextIfNeeded() needsDisplay = true } @@ -22,6 +23,7 @@ extension TextView { return linePosition.range } selectionManager.setSelectedRanges(newSelections) + unmarkTextIfNeeded() needsDisplay = true } @@ -58,6 +60,7 @@ extension TextView { ) } selectionManager.setSelectedRanges(newSelections) + unmarkTextIfNeeded() needsDisplay = true } } diff --git a/Sources/CodeEditInputView/TextView/TextView+Setup.swift b/Sources/CodeEditInputView/TextView/TextView+Setup.swift index 605ef74c3..e29901e99 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Setup.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Setup.swift @@ -13,7 +13,7 @@ extension TextView { textStorage: textStorage, lineHeightMultiplier: lineHeight, wrapLines: wrapLines, - textView: self, // TODO: This is an odd syntax... consider reworking this + textView: self, delegate: self ) } @@ -22,7 +22,7 @@ extension TextView { TextSelectionManager( layoutManager: layoutManager, textStorage: textStorage, - layoutView: self, // TODO: This is an odd syntax... consider reworking this + layoutView: self, delegate: self ) } diff --git a/Sources/CodeEditInputView/TextView/TextView+TextLayoutManagerDelegate.swift b/Sources/CodeEditInputView/TextView/TextView+TextLayoutManagerDelegate.swift index 8298cf30e..d89a635a7 100644 --- a/Sources/CodeEditInputView/TextView/TextView+TextLayoutManagerDelegate.swift +++ b/Sources/CodeEditInputView/TextView/TextView+TextLayoutManagerDelegate.swift @@ -16,7 +16,7 @@ extension TextView: TextLayoutManagerDelegate { updateFrameIfNeeded() } - public func layoutManagerTypingAttributes() -> [NSAttributedString.Key : Any] { + public func layoutManagerTypingAttributes() -> [NSAttributedString.Key: Any] { typingAttributes } diff --git a/Sources/CodeEditInputView/TextView/TextView.swift b/Sources/CodeEditInputView/TextView/TextView.swift index f0cf5bda7..f59eac172 100644 --- a/Sources/CodeEditInputView/TextView/TextView.swift +++ b/Sources/CodeEditInputView/TextView/TextView.swift @@ -6,7 +6,6 @@ // import AppKit -import Common import TextStory // Disabling file length and type body length as the methods and variables contained in this file cannot be moved @@ -153,6 +152,15 @@ public class TextView: NSView, NSTextContent { } } + public var lineBreakStrategy: LineBreakStrategy { + get { + layoutManager?.lineBreakStrategy ?? .word + } + set { + layoutManager.lineBreakStrategy = newValue + } + } + open var contentType: NSTextContentType? public weak var delegate: TextViewDelegate? @@ -330,6 +338,11 @@ public class TextView: NSView, NSTextContent { // MARK: - View Lifecycle + override public func layout() { + layoutManager.layoutLines() + super.layout() + } + override public func viewWillMove(toWindow newWindow: NSWindow?) { super.viewWillMove(toWindow: newWindow) layoutManager.layoutLines() diff --git a/Sources/CodeEditInputView/Utils/CEUndoManager.swift b/Sources/CodeEditInputView/Utils/CEUndoManager.swift index 039a70f7e..ad5550dc6 100644 --- a/Sources/CodeEditInputView/Utils/CEUndoManager.swift +++ b/Sources/CodeEditInputView/Utils/CEUndoManager.swift @@ -208,7 +208,7 @@ public class CEUndoManager { } // MARK: - Disable - + /// Sets the undo manager to ignore incoming mutations until the matching `enable` method is called. /// Cannot be nested. public func disable() { diff --git a/Sources/Common/MultiStorageDelegate.swift b/Sources/CodeEditInputView/Utils/MultiStorageDelegate.swift similarity index 100% rename from Sources/Common/MultiStorageDelegate.swift rename to Sources/CodeEditInputView/Utils/MultiStorageDelegate.swift diff --git a/Sources/Common/ViewReuseQueue.swift b/Sources/CodeEditInputView/Utils/ViewReuseQueue.swift similarity index 100% rename from Sources/Common/ViewReuseQueue.swift rename to Sources/CodeEditInputView/Utils/ViewReuseQueue.swift diff --git a/Sources/CodeEditTextView/Controller/TextViewController.swift b/Sources/CodeEditTextView/Controller/TextViewController.swift index fa0562710..42db7547e 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController.swift @@ -9,7 +9,6 @@ import AppKit import CodeEditInputView import CodeEditLanguages import SwiftUI -import Common import Combine import TextFormation diff --git a/Sources/CodeEditTextView/Gutter/GutterView.swift b/Sources/CodeEditTextView/Gutter/GutterView.swift index aeca795b2..3abb9f8c0 100644 --- a/Sources/CodeEditTextView/Gutter/GutterView.swift +++ b/Sources/CodeEditTextView/Gutter/GutterView.swift @@ -7,7 +7,6 @@ import AppKit import CodeEditInputView -import Common public protocol GutterViewDelegate: AnyObject { func gutterViewWidthDidUpdate(newWidth: CGFloat) diff --git a/Tests/CodeEditInputViewTests/TextSelectionManagerTests.swift b/Tests/CodeEditInputViewTests/TextSelectionManagerTests.swift index c77358a0e..bd4f623fa 100644 --- a/Tests/CodeEditInputViewTests/TextSelectionManagerTests.swift +++ b/Tests/CodeEditInputViewTests/TextSelectionManagerTests.swift @@ -5,8 +5,8 @@ final class TextSelectionManagerTests: XCTestCase { var textStorage: NSTextStorage! var layoutManager: TextLayoutManager! - override func setUp() { - textStorage = NSTextStorage(string: "Loren Ipsum 💯") + func selectionManager(_ text: String = "Loren Ipsum 💯") -> TextSelectionManager { + textStorage = NSTextStorage(string: text) layoutManager = TextLayoutManager( textStorage: textStorage, lineHeightMultiplier: 1.0, @@ -14,10 +14,7 @@ final class TextSelectionManagerTests: XCTestCase { textView: NSView(), delegate: nil ) - } - - func selectionManager() -> TextSelectionManager { - TextSelectionManager( + return TextSelectionManager( layoutManager: layoutManager, textStorage: textStorage, layoutView: nil, @@ -76,18 +73,148 @@ final class TextSelectionManagerTests: XCTestCase { } func test_updateSelectionLeftWord() { - // TODO + let selectionManager = selectionManager() + let locations = [2, 0, 12] + let expectedRanges = [(0, 2), (0, 0), (6, 6)] + + for idx in locations.indices { + let range = selectionManager.rangeOfSelection( + from: locations[idx], + direction: .backward, + destination: .word, + decomposeCharacters: false + ) + + XCTAssert( + range.location == expectedRanges[idx].0, + "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" + ) + XCTAssert( + range.length == expectedRanges[idx].1, + "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" + ) + } } func test_updateSelectionRightWord() { - // TODO + // "Loren Ipsum 💯" + let selectionManager = selectionManager() + let locations = [2, 0, 6] + let expectedRanges = [(2, 3), (0, 5), (6, 5)] + + for idx in locations.indices { + let range = selectionManager.rangeOfSelection( + from: locations[idx], + direction: .forward, + destination: .word, + decomposeCharacters: false + ) + + XCTAssert( + range.location == expectedRanges[idx].0, + "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" + ) + XCTAssert( + range.length == expectedRanges[idx].1, + "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" + ) + } } func test_updateSelectionLeftLine() { - // TODO + // "Loren Ipsum 💯" + let selectionManager = selectionManager() + let locations = [2, 0, 14, 12] + let expectedRanges = [(0, 2), (0, 0), (0, 14), (0, 12)] + + for idx in locations.indices { + let range = selectionManager.rangeOfSelection( + from: locations[idx], + direction: .backward, + destination: .line, + decomposeCharacters: false + ) + + XCTAssert( + range.location == expectedRanges[idx].0, + "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" + ) + XCTAssert( + range.length == expectedRanges[idx].1, + "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" + ) + } } func test_updateSelectionRightLine() { - // TODO + let selectionManager = selectionManager("Loren Ipsum 💯\nHello World") + let locations = [2, 0, 14, 12, 17] + let expectedRanges = [(2, 12), (0, 14), (14, 0), (12, 2), (17, 9)] + + for idx in locations.indices { + let range = selectionManager.rangeOfSelection( + from: locations[idx], + direction: .forward, + destination: .line, + decomposeCharacters: false + ) + + XCTAssert( + range.location == expectedRanges[idx].0, + "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" + ) + XCTAssert( + range.length == expectedRanges[idx].1, + "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" + ) + } + } + + func test_updateSelectionUpDocument() { + let selectionManager = selectionManager("Loren Ipsum 💯\nHello World\n1\n2\n3\n") + let locations = [0, 27, 30, 33] + let expectedRanges = [(0, 0), (0, 27), (0, 30), (0, 33)] + + for idx in locations.indices { + let range = selectionManager.rangeOfSelection( + from: locations[idx], + direction: .up, + destination: .document, + decomposeCharacters: false + ) + + XCTAssert( + range.location == expectedRanges[idx].0, + "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" + ) + XCTAssert( + range.length == expectedRanges[idx].1, + "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" + ) + } + } + + func test_updateSelectionDownDocument() { + let selectionManager = selectionManager("Loren Ipsum 💯\nHello World\n1\n2\n3\n") + let locations = [0, 2, 27, 30, 33] + let expectedRanges = [(0, 33), (2, 31), (27, 6), (30, 3), (33, 0)] + + for idx in locations.indices { + let range = selectionManager.rangeOfSelection( + from: locations[idx], + direction: .down, + destination: .document, + decomposeCharacters: false + ) + + XCTAssert( + range.location == expectedRanges[idx].0, + "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" + ) + XCTAssert( + range.length == expectedRanges[idx].1, + "Invalid Location. Testing location \(locations[idx]). Expected \(expectedRanges[idx]). Got \(range)" + ) + } } } From 81c7f980cffbbb541ae571d8878c1dc436f839cb Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 18 Nov 2023 08:47:10 -0600 Subject: [PATCH 70/75] Update SwiftUI API, Add `CursorPosition` & `TextViewCoordinator` --- .../TextLayoutManager/TextLayoutManager.swift | 6 +- .../TextLineStorage+NSTextStorage.swift | 4 +- .../TextSelectionManager.swift | 18 ++- .../TextView/TextView+Mouse.swift | 4 + .../TextView/TextView+Setup.swift | 4 +- .../TextView/TextView+StorageDelegate.swift | 18 +++ .../CodeEditInputView/TextView/TextView.swift | 95 +++++++------ .../Utils/CEUndoManager.swift | 8 +- Sources/CodeEditInputView/Utils/Logger.swift | 3 + .../CodeEditTextView/CodeEditTextView.swift | 75 +++++++--- .../Controller/CursorPosition.swift | 67 +++++++++ .../TextViewController+Cursor.swift | 67 +++++---- .../TextViewController+Highlighter.swift | 6 +- .../TextViewController+LoadView.swift | 18 +-- .../TextViewController+TextFormation.swift | 2 +- .../TextViewController+TextViewDelegate.swift | 2 +- .../Controller/TextViewController.swift | 61 ++++++-- .../CodeEditTextView/Gutter/GutterView.swift | 2 + .../TextViewCoordinator.swift | 41 ++++++ .../TextViewControllerTests.swift | 132 ++++++++++++++++-- 20 files changed, 489 insertions(+), 144 deletions(-) create mode 100644 Sources/CodeEditInputView/TextView/TextView+StorageDelegate.swift create mode 100644 Sources/CodeEditInputView/Utils/Logger.swift create mode 100644 Sources/CodeEditTextView/Controller/CursorPosition.swift create mode 100644 Sources/CodeEditTextView/TextViewCoordinator.swift diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift index eeaa351d0..f668029b4 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift @@ -122,6 +122,7 @@ public class TextLayoutManager: NSObject { internal func prepareTextLines() { guard lineStorage.count == 0, let textStorage else { return } #if DEBUG + // Grab some performance information if debugging. var info = mach_timebase_info() guard mach_timebase_info(&info) == KERN_SUCCESS else { return } let start = mach_absolute_time() @@ -134,7 +135,8 @@ public class TextLayoutManager: NSObject { 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") + let msec = TimeInterval(nanos) / TimeInterval(NSEC_PER_MSEC) + logger.info("TextLayoutManager built in: \(msec, privacy: .public)ms") #endif } @@ -229,6 +231,7 @@ public class TextLayoutManager: NSObject { /// Lays out all visible lines internal func layoutLines() { // swiftlint:disable:this function_body_length guard let visibleRect = delegate?.visibleRect, !isInTransaction, let textStorage else { return } + CATransaction.begin() let minY = max(visibleRect.minY, 0) let maxY = max(visibleRect.maxY, 0) let originalHeight = lineStorage.height @@ -294,6 +297,7 @@ public class TextLayoutManager: NSObject { } needsLayout = false + CATransaction.commit() } /// Lays out a single text line. diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift index 9848ffaa8..abf05b07e 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+NSTextStorage.swift @@ -1,6 +1,6 @@ // -// File.swift -// +// TextLineStorage+NSTextStorage.swift +// // // Created by Khan Winter on 8/21/23. // diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift index 36fa0456a..8b49701be 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift @@ -21,7 +21,7 @@ public protocol TextSelectionManagerDelegate: AnyObject { public class TextSelectionManager: NSObject { // MARK: - TextSelection - public class TextSelection: Hashable { + public class TextSelection: Hashable, Equatable { public var range: NSRange internal weak var view: CursorView? internal var boundingRect: CGRect = .zero @@ -112,11 +112,17 @@ public class TextSelectionManager: NSObject { public func setSelectedRanges(_ ranges: [NSRange]) { textSelections.forEach { $0.view?.removeFromSuperview() } - textSelections = Set(ranges).map { - let selection = TextSelection(range: $0) - selection.suggestedXPos = layoutManager?.rectForOffset($0.location)?.minX - return selection - } + // Remove duplicates, invalid ranges, update suggested X position. + textSelections = Set(ranges) + .filter { + (0...(textStorage?.length ?? 0)).contains($0.location) + && (0...(textStorage?.length ?? 0)).contains($0.max) + } + .map { + let selection = TextSelection(range: $0) + selection.suggestedXPos = layoutManager?.rectForOffset($0.location)?.minX + return selection + } updateSelectionViews() NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) } diff --git a/Sources/CodeEditInputView/TextView/TextView+Mouse.swift b/Sources/CodeEditInputView/TextView/TextView+Mouse.swift index 3b4300120..77fee3682 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Mouse.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Mouse.swift @@ -19,6 +19,10 @@ extension TextView { switch event.clickCount { case 1: + guard isEditable else { + super.mouseDown(with: event) + return + } if event.modifierFlags.intersection(.deviceIndependentFlagsMask).isSuperset(of: [.control, .shift]) { unmarkText() selectionManager.addSelectedRange(NSRange(location: offset, length: 0)) diff --git a/Sources/CodeEditInputView/TextView/TextView+Setup.swift b/Sources/CodeEditInputView/TextView/TextView+Setup.swift index e29901e99..003304591 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Setup.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Setup.swift @@ -8,10 +8,10 @@ import AppKit extension TextView { - internal func setUpLayoutManager(lineHeight: CGFloat, wrapLines: Bool) -> TextLayoutManager { + internal func setUpLayoutManager(lineHeightMultiplier: CGFloat, wrapLines: Bool) -> TextLayoutManager { TextLayoutManager( textStorage: textStorage, - lineHeightMultiplier: lineHeight, + lineHeightMultiplier: lineHeightMultiplier, wrapLines: wrapLines, textView: self, delegate: self diff --git a/Sources/CodeEditInputView/TextView/TextView+StorageDelegate.swift b/Sources/CodeEditInputView/TextView/TextView+StorageDelegate.swift new file mode 100644 index 000000000..d13bd234d --- /dev/null +++ b/Sources/CodeEditInputView/TextView/TextView+StorageDelegate.swift @@ -0,0 +1,18 @@ +// +// TextView+StorageDelegate.swift +// +// +// Created by Khan Winter on 11/8/23. +// + +import AppKit + +extension TextView { + public func addStorageDelegate(_ delegate: NSTextStorageDelegate) { + storageDelegate.addDelegate(delegate) + } + + public func removeStorageDelegate(_ delegate: NSTextStorageDelegate) { + storageDelegate.removeDelegate(delegate) + } +} diff --git a/Sources/CodeEditInputView/TextView/TextView.swift b/Sources/CodeEditInputView/TextView/TextView.swift index f59eac172..99f3fdb1f 100644 --- a/Sources/CodeEditInputView/TextView/TextView.swift +++ b/Sources/CodeEditInputView/TextView/TextView.swift @@ -9,26 +9,38 @@ import AppKit import TextStory // Disabling file length and type body length as the methods and variables contained in this file cannot be moved -// to extensions without a lot of work. +// to extensions. // swiftlint:disable type_body_length +/// # Text View /// +/// A view that draws and handles user interactions with text. +/// Optimized for line-based documents, does not attempt to have feature parity with `NSTextView`. +/// +/// The text view maintains multiple helper classes for selecting, editing, and laying out text. /// ``` /// TextView -/// |-> 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, stored in a line storage for long lines +/// |-> NSTextStorage Base text storage. +/// |-> TextLayoutManager Creates, manages, and lays out text lines. +/// | |-> TextLineStorage Extremely fast object for storing and querying lines of text. Does not store text. +/// | |-> [TextLine] Represents a line of text. +/// | | |-> Typesetter Calculates line breaks and other layout information for text lines. +/// | | |-> [LineFragment] Represents a visual line of text, stored in an internal line storage object. /// | |-> [LineFragmentView] Reusable line fragment view that draws a line fragment. -/// | |-> MarkedRangeManager Manages marked ranges, updates layout if needed to accomodate. +/// | |-> MarkedRangeManager Manages marked ranges, updates layout if needed. /// | /// |-> TextSelectionManager Maintains, modifies, and renders text selections -/// | |-> [TextSelection] +/// | |-> [TextSelection] Represents a range of selected text. /// ``` +/// +/// Conforms to [`NSTextContent`](https://developer.apple.com/documentation/appkit/nstextcontent) and +/// [`NSTextInputClient`](https://developer.apple.com/documentation/appkit/nstextinputclient) to work well with system +/// text interactions such as inserting text and marked text. +/// public class TextView: NSView, NSTextContent { // MARK: - Statics - /// The default typing attributes. Defaults to: + /// The default typing attributes: /// - font: System font, size 12 /// - foregroundColor: System text color /// - kern: 0.0 @@ -44,6 +56,7 @@ public class TextView: NSView, NSTextContent { // MARK: - Configuration + /// The string for the text view. public var string: String { get { textStorage.string @@ -111,6 +124,7 @@ public class TextView: NSView, NSTextContent { } } + /// Determines if the text view's content can be edited. public var isEditable: Bool { didSet { setNeedsDisplay() @@ -121,6 +135,7 @@ public class TextView: NSView, NSTextContent { } } + /// Determines if the text view responds to selection events, such as clicks. public var isSelectable: Bool = true { didSet { if !isSelectable { @@ -133,6 +148,7 @@ public class TextView: NSView, NSTextContent { } } + /// The edge insets for the text view. public var edgeInsets: HorizontalEdgeInsets { get { layoutManager?.edgeInsets ?? .zero @@ -152,6 +168,7 @@ public class TextView: NSView, NSTextContent { } } + /// The strategy to use when breaking lines. Defaults to ``LineBreakStrategy/word``. public var lineBreakStrategy: LineBreakStrategy { get { layoutManager?.lineBreakStrategy ?? .word @@ -163,10 +180,17 @@ public class TextView: NSView, NSTextContent { open var contentType: NSTextContentType? + /// The text view's delegate. public weak var delegate: TextViewDelegate? + /// The text storage object for the text view. + /// - Warning: Do not update the text storage object directly. Doing so will very likely break the text view's + /// layout system. Use methods like ``TextView/replaceCharacters(in:with:)-3h9uo`` or + /// ``TextView/insertText(_:)`` to modify content. private(set) public var textStorage: NSTextStorage! + /// The layout manager for the text view. private(set) public var layoutManager: TextLayoutManager! + /// The selection manager for the text view. private(set) public var selectionManager: TextSelectionManager! // MARK: - Private Properties @@ -187,51 +211,34 @@ public class TextView: NSView, NSTextContent { return enclosingScrollView } - private weak var storageDelegate: MultiStorageDelegate! + internal var storageDelegate: MultiStorageDelegate! // MARK: - Init - public convenience init( - string: String, - font: NSFont, - textColor: NSColor, - lineHeight: CGFloat, - wrapLines: Bool, - isEditable: Bool, - isSelectable: Bool, - letterSpacing: Double, - delegate: TextViewDelegate, - storageDelegate: MultiStorageDelegate - ) { - self.init( - textStorage: NSTextStorage(string: string), - font: font, - textColor: textColor, - lineHeight: lineHeight, - wrapLines: wrapLines, - isEditable: isEditable, - isSelectable: isSelectable, - letterSpacing: letterSpacing, - delegate: delegate, - storageDelegate: storageDelegate - ) - } - + /// Initializes the text view. + /// - Parameters: + /// - string: The contents of the text view. + /// - font: The default font. + /// - textColor: The default text color. + /// - lineHeightMultiplier: The multiplier to use for line heights. + /// - wrapLines: Determines how the view will wrap lines to the viewport. + /// - isEditable: Determines if the view is editable. + /// - isSelectable: Determines if the view is selectable. + /// - letterSpacing: Sets the letter spacing on the view. + /// - delegate: The text view's delegate. public init( - textStorage: NSTextStorage, + string: String, font: NSFont, textColor: NSColor, - lineHeight: CGFloat, + lineHeightMultiplier: CGFloat, wrapLines: Bool, isEditable: Bool, isSelectable: Bool, letterSpacing: Double, - delegate: TextViewDelegate, - storageDelegate: MultiStorageDelegate + delegate: TextViewDelegate ) { + self.textStorage = NSTextStorage(string: string) self.delegate = delegate - self.textStorage = textStorage - self.storageDelegate = storageDelegate self.isEditable = isEditable self.isSelectable = isSelectable self.letterSpacing = letterSpacing @@ -239,6 +246,8 @@ public class TextView: NSView, NSTextContent { super.init(frame: .zero) + self.storageDelegate = MultiStorageDelegate() + wantsLayer = true postsFrameChangedNotifications = true postsBoundsChangedNotifications = true @@ -252,7 +261,7 @@ public class TextView: NSView, NSTextContent { textStorage.addAttributes(typingAttributes, range: documentRange) textStorage.delegate = storageDelegate - layoutManager = setUpLayoutManager(lineHeight: lineHeight, wrapLines: wrapLines) + layoutManager = setUpLayoutManager(lineHeightMultiplier: lineHeightMultiplier, wrapLines: wrapLines) storageDelegate.addDelegate(layoutManager) selectionManager = setUpSelectionManager() @@ -283,7 +292,7 @@ public class TextView: NSView, NSTextContent { layoutManager.reset() selectionManager.textStorage = textStorage - selectionManager.textSelections.removeAll() + selectionManager.setSelectedRanges(selectionManager.textSelections.map { $0.range }) _undoManager?.clearStack() diff --git a/Sources/CodeEditInputView/Utils/CEUndoManager.swift b/Sources/CodeEditInputView/Utils/CEUndoManager.swift index ad5550dc6..b65e033cf 100644 --- a/Sources/CodeEditInputView/Utils/CEUndoManager.swift +++ b/Sources/CodeEditInputView/Utils/CEUndoManager.swift @@ -78,12 +78,16 @@ public class CEUndoManager { // MARK: - Init - public init(textView: TextView) { - self.textView = textView + public init() { self.manager = DelegatedUndoManager() manager.parent = self } + convenience init(textView: TextView) { + self.init() + self.textView = textView + } + // MARK: - Undo/Redo /// Performs an undo operation if there is one available. diff --git a/Sources/CodeEditInputView/Utils/Logger.swift b/Sources/CodeEditInputView/Utils/Logger.swift new file mode 100644 index 000000000..543b8e5d7 --- /dev/null +++ b/Sources/CodeEditInputView/Utils/Logger.swift @@ -0,0 +1,3 @@ +import os + +let logger = Logger(subsystem: "com.CodeEdit.CodeEditTextView", category: "TextView") diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index 48c4acbc5..79d9b2c98 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditTextView/CodeEditTextView.swift @@ -47,7 +47,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { lineHeight: Double, wrapLines: Bool, editorOverscroll: CGFloat = 0, - cursorPosition: Binding<(Int, Int)>, + cursorPositions: Binding<[CursorPosition]>, useThemeBackground: Bool = true, highlightProvider: HighlightProviding? = nil, contentInsets: NSEdgeInsets? = nil, @@ -55,7 +55,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable { isSelectable: Bool = true, letterSpacing: Double = 1.0, bracketPairHighlight: BracketPairHighlight? = nil, - undoManager: CEUndoManager? = nil + undoManager: CEUndoManager? = nil, + coordinators: [any TextViewCoordinator] = [] ) { self._text = text self.language = language @@ -67,7 +68,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { self.lineHeight = lineHeight self.wrapLines = wrapLines self.editorOverscroll = editorOverscroll - self._cursorPosition = cursorPosition + self._cursorPositions = cursorPositions self.highlightProvider = highlightProvider self.contentInsets = contentInsets self.isEditable = isEditable @@ -75,6 +76,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { self.letterSpacing = letterSpacing self.bracketPairHighlight = bracketPairHighlight self.undoManager = undoManager + self.coordinators = coordinators } @Binding private var text: String @@ -86,7 +88,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { private var lineHeight: Double private var wrapLines: Bool private var editorOverscroll: CGFloat - @Binding private var cursorPosition: (Int, Int) + @Binding private var cursorPositions: [CursorPosition] private var useThemeBackground: Bool private var highlightProvider: HighlightProviding? private var contentInsets: NSEdgeInsets? @@ -95,12 +97,13 @@ public struct CodeEditTextView: NSViewControllerRepresentable { private var letterSpacing: Double private var bracketPairHighlight: BracketPairHighlight? private var undoManager: CEUndoManager? + private var coordinators: [any TextViewCoordinator] public typealias NSViewControllerType = TextViewController public func makeNSViewController(context: Context) -> TextViewController { let controller = TextViewController( - string: _text.wrappedValue, + string: text, language: language, font: font, theme: theme, @@ -108,7 +111,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { indentOption: indentOption, lineHeight: lineHeight, wrapLines: wrapLines, - cursorPosition: $cursorPosition, + cursorPositions: cursorPositions, editorOverscroll: editorOverscroll, useThemeBackground: useThemeBackground, highlightProvider: highlightProvider, @@ -119,7 +122,17 @@ public struct CodeEditTextView: NSViewControllerRepresentable { bracketPairHighlight: bracketPairHighlight, undoManager: undoManager ) + if controller.textView == nil { + controller.loadView() + } + if !cursorPositions.isEmpty { + controller.setCursorPositions(cursorPositions) + } + context.coordinator.controller = controller + coordinators.forEach { + $0.prepareCoordinator(controller: controller) + } return controller } @@ -128,17 +141,19 @@ public struct CodeEditTextView: NSViewControllerRepresentable { } 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 - } - if !context.coordinator.isUpdateFromTextView { // Prevent infinite loop of update notifications context.coordinator.isUpdatingFromRepresentable = true - controller.setText(_text.wrappedValue) + controller.setCursorPositions(cursorPositions) context.coordinator.isUpdatingFromRepresentable = false + } else { + context.coordinator.isUpdateFromTextView = false + } + + // 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 @@ -194,6 +209,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { controller.bracketPairHighlight == bracketPairHighlight } + @MainActor public class Coordinator: NSObject { var parent: CodeEditTextView var controller: TextViewController? @@ -210,19 +226,46 @@ public struct CodeEditTextView: NSViewControllerRepresentable { name: TextView.textDidChangeNotification, object: nil ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(textControllerCursorsDidUpdate(_:)), + name: TextViewController.cursorPositionUpdatedNotification, + object: nil + ) } @objc func textViewDidChangeText(_ notification: Notification) { guard let textView = notification.object as? TextView, - controller?.textView === textView, - !isUpdatingFromRepresentable else { + let controller, + controller.textView === textView else { return } - isUpdateFromTextView = true parent.text = textView.string + parent.coordinators.forEach { + $0.textViewDidChangeText(controller: controller) + } + } + + @objc func textControllerCursorsDidUpdate(_ notification: Notification) { + guard !isUpdatingFromRepresentable else { return } + self.isUpdateFromTextView = true + self.parent._cursorPositions.wrappedValue = self.controller?.cursorPositions ?? [] + if self.controller != nil { + self.parent.coordinators.forEach { + $0.textViewDidChangeSelection( + controller: self.controller!, + newPositions: self.controller!.cursorPositions + ) + } + } } deinit { + parent.coordinators.forEach { + $0.destroy() + } + parent.coordinators.removeAll() NotificationCenter.default.removeObserver(self) } } diff --git a/Sources/CodeEditTextView/Controller/CursorPosition.swift b/Sources/CodeEditTextView/Controller/CursorPosition.swift new file mode 100644 index 000000000..a399626e2 --- /dev/null +++ b/Sources/CodeEditTextView/Controller/CursorPosition.swift @@ -0,0 +1,67 @@ +// +// CursorPosition.swift +// CodeEditTextView +// +// Created by Khan Winter on 11/13/23. +// + +import Foundation + +/// # Cursor Position +/// +/// Represents the position of a cursor in a document. +/// Provides information about the range of the selection relative to the document, and the line-column information. +/// +/// Can be initialized by users without knowledge of either column and line position or range in the document. +/// When initialized by users, certain values may be set to `NSNotFound` or `-1` until they can be filled in by the text +/// controller. +/// +public struct CursorPosition: Sendable, Codable, Equatable { + /// Initialize a cursor position. + /// + /// When this initializer is used, ``CursorPosition/range`` will be initialized to `NSNotFound`. + /// The range value, however, be filled when updated by ``CodeEditTextView`` via a `Binding`, or when it appears + /// in the``TextViewController/cursorPositions`` array. + /// + /// - Parameters: + /// - line: The line of the cursor position, 1-indexed. + /// - column: The column of the cursor position, 1-indexed. + public init(line: Int, column: Int) { + self.range = .notFound + self.line = line + self.column = column + } + + /// Initialize a cursor position. + /// + /// When this initializer is used, both ``CursorPosition/line`` and ``CursorPosition/column`` will be initialized + /// to `-1`. They will, however, be filled when updated by ``CodeEditTextView`` via a `Binding`, or when it + /// appears in the ``TextViewController/cursorPositions`` array. + /// + /// - Parameter range: The range of the cursor position. + public init(range: NSRange) { + self.range = range + self.line = -1 + self.column = -1 + } + + /// Private initializer. + /// - Parameters: + /// - range: The range of the position. + /// - line: The line of the position. + /// - column: The column of the position. + init(range: NSRange, line: Int, column: Int) { + self.range = range + self.line = line + self.column = column + } + + /// The range of the selection. + public let range: NSRange + /// The line the cursor is located at. 1-indexed. + /// If ``CursorPosition/range`` is not empty, this is the line at the beginning of the selection. + public let line: Int + /// The column the cursor is located at. 1-indexed. + /// If ``CursorPosition/range`` is not empty, this is the column at the beginning of the selection. + public let column: Int +} diff --git a/Sources/CodeEditTextView/Controller/TextViewController+Cursor.swift b/Sources/CodeEditTextView/Controller/TextViewController+Cursor.swift index 8f5247b47..b1e977535 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController+Cursor.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController+Cursor.swift @@ -1,6 +1,6 @@ // // TextViewController+Cursor.swift -// +// CodeEditTextView // // Created by Elias Wahl on 15.03.23. // @@ -9,41 +9,48 @@ import Foundation import AppKit extension TextViewController { - /// Sets a new cursor position. - /// - Parameter position: The position to set. Lines and columns are 1-indexed. - func setCursorPosition(_ position: (Int, Int)) { - let (line, column) = position - guard line > 0 && column > 0 else { return } - + /// Sets new cursor positions. + /// - Parameter positions: The positions to set. Lines and columns are 1-indexed. + public func setCursorPositions(_ positions: [CursorPosition]) { _ = textView.becomeFirstResponder() - if textView.textStorage.length == 0 { - // If the file is blank, automatically place the cursor in the first index. - let range = NSRange(location: 0, length: 0) - self.textView.selectionManager.setSelectedRange(range) - } else if let linePosition = textView.layoutManager.textLineForIndex(line - 1) { - // If this is a valid line, set the new position - let index = max( - linePosition.range.lowerBound, - min(linePosition.range.upperBound, column - 1) - ) - self.textView.selectionManager.setSelectedRange(NSRange(location: index, length: 0)) + var newSelectedRanges: [NSRange] = [] + for position in positions { + let line = position.line + let column = position.column + guard (line > 0 && column > 0) || (position.range != .notFound) else { continue } + + if position.range == .notFound { + if textView.textStorage.length == 0 { + // If the file is blank, automatically place the cursor in the first index. + newSelectedRanges.append(NSRange(location: 0, length: 0)) + } else if let linePosition = textView.layoutManager.textLineForIndex(line - 1) { + // If this is a valid line, set the new position + let index = max( + linePosition.range.lowerBound, + min(linePosition.range.upperBound, column - 1) + ) + newSelectedRanges.append(NSRange(location: index, length: 0)) + } + } else { + newSelectedRanges.append(position.range) + } } + textView.selectionManager.setSelectedRanges(newSelectedRanges) } + /// Update the ``TextViewController/cursorPositions`` variable with new text selections from the text view. func updateCursorPosition() { - // Get the smallest cursor position. - guard let selectedRange = textView - .selectionManager - .textSelections - .sorted(by: { $0.range.lowerBound < $1.range.lowerBound}) - .first else { - return + var positions: [CursorPosition] = [] + for selectedRange in textView.selectionManager.textSelections { + guard let linePosition = textView.layoutManager.textLineForOffset(selectedRange.range.location) else { + continue + } + let column = (selectedRange.range.location - linePosition.range.location) + 1 + let row = linePosition.index + 1 + positions.append(CursorPosition(range: selectedRange.range, line: row, column: column)) } - - // Get the line it's in - guard let linePosition = textView.layoutManager.textLineForOffset(selectedRange.range.location) else { return } - let column = selectedRange.range.location - linePosition.range.location - cursorPosition.wrappedValue = (linePosition.index + 1, column + 1) + cursorPositions = positions.sorted(by: { $0.range.location < $1.range.location }) + NotificationCenter.default.post(name: Self.cursorPositionUpdatedNotification, object: nil) } } diff --git a/Sources/CodeEditTextView/Controller/TextViewController+Highlighter.swift b/Sources/CodeEditTextView/Controller/TextViewController+Highlighter.swift index c6b442a9b..1f1c1201f 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController+Highlighter.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController+Highlighter.swift @@ -1,6 +1,6 @@ // // TextViewController+Highlighter.swift -// +// CodeEditTextView // // Created by Khan Winter on 10/14/23. // @@ -11,7 +11,7 @@ import SwiftTreeSitter extension TextViewController { internal func setUpHighlighter() { if let highlighter { - storageDelegate.removeDelegate(highlighter) + textView.removeStorageDelegate(highlighter) self.highlighter = nil } @@ -22,7 +22,7 @@ extension TextViewController { attributeProvider: self, language: language ) - storageDelegate.addDelegate(highlighter!) + textView.addStorageDelegate(highlighter!) setHighlightProvider(self.highlightProvider) } diff --git a/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift b/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift index 503065202..668f05a4b 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift @@ -1,6 +1,6 @@ // // TextViewController+LoadView.swift -// +// CodeEditTextView // // Created by Khan Winter on 10/14/23. // @@ -12,18 +12,6 @@ extension TextViewController { // swiftlint:disable:next function_body_length override public func loadView() { scrollView = NSScrollView() - textView = TextView( - textStorage: textStorage, - font: font, - textColor: theme.text, - lineHeight: lineHeightMultiple, - wrapLines: wrapLines, - isEditable: isEditable, - isSelectable: isSelectable, - letterSpacing: letterSpacing, - delegate: self, - storageDelegate: storageDelegate - ) textView.postsFrameChangedNotifications = true textView.translatesAutoresizingMaskIntoConstraints = false textView.selectionManager.insertionPointColor = theme.insertionPoint @@ -68,6 +56,10 @@ extension TextViewController { scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) + if !cursorPositions.isEmpty { + setCursorPositions(cursorPositions) + } + // Layout on scroll change NotificationCenter.default.addObserver( forName: NSView.boundsDidChangeNotification, diff --git a/Sources/CodeEditTextView/Controller/TextViewController+TextFormation.swift b/Sources/CodeEditTextView/Controller/TextViewController+TextFormation.swift index 2ce8dd31d..07f376662 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController+TextFormation.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController+TextFormation.swift @@ -1,6 +1,6 @@ // // TextViewController+TextFormation.swift -// +// CodeEditTextView // // Created by Khan Winter on 1/26/23. // diff --git a/Sources/CodeEditTextView/Controller/TextViewController+TextViewDelegate.swift b/Sources/CodeEditTextView/Controller/TextViewController+TextViewDelegate.swift index 667489ec4..4707af07b 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController+TextViewDelegate.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController+TextViewDelegate.swift @@ -1,6 +1,6 @@ // // TextViewController+TextViewDelegate.swift -// +// CodeEditTextView // // Created by Khan Winter on 10/14/23. // diff --git a/Sources/CodeEditTextView/Controller/TextViewController.swift b/Sources/CodeEditTextView/Controller/TextViewController.swift index 42db7547e..6c3bfe25e 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController.swift @@ -1,6 +1,6 @@ // // TextViewController.swift -// +// CodeEditTextView // // Created by Khan Winter on 6/25/23. // @@ -12,7 +12,15 @@ import SwiftUI import Combine import TextFormation +/// # TextViewController +/// +/// A view controller class for managing a source editor. Uses ``CodeEditInputView/TextView`` for input and rendering, +/// tree-sitter for syntax highlighting, and TextFormation for live editing completions. +/// public class TextViewController: NSViewController { + // swiftlint:disable:next line_length + public static let cursorPositionUpdatedNotification: Notification.Name = .init("TextViewController.cursorPositionNotification") + var scrollView: NSScrollView! var textView: TextView! var gutterView: GutterView! @@ -23,7 +31,7 @@ public class TextViewController: NSViewController { /// The string contents. public var string: String { - textStorage.string + textView.string } /// The associated `CodeLanguage` @@ -37,6 +45,7 @@ public class TextViewController: NSViewController { public var font: NSFont { didSet { textView.font = font + highlighter?.invalidate() } } @@ -44,7 +53,10 @@ public class TextViewController: NSViewController { public var theme: EditorTheme { didSet { textView.layoutManager.setNeedsLayout() - textStorage.setAttributes(attributesFor(nil), range: NSRange(location: 0, length: textStorage.length)) + textView.textStorage.setAttributes( + attributesFor(nil), + range: NSRange(location: 0, length: textView.textStorage.length) + ) highlighter?.invalidate() } } @@ -53,6 +65,8 @@ public class TextViewController: NSViewController { public var tabWidth: Int { didSet { paragraphStyle = generateParagraphStyle() + textView.layoutManager.setNeedsLayout() + highlighter?.invalidate() } } @@ -77,8 +91,8 @@ public class TextViewController: NSViewController { } } - /// The current cursor position e.g. (1, 1) - public var cursorPosition: Binding<(Int, Int)> + /// The current cursors' positions ordered by the location of the cursor. + internal(set) public var cursorPositions: [CursorPosition] = [] /// The editorOverscroll to use for the textView over scroll /// @@ -114,6 +128,7 @@ public class TextViewController: NSViewController { public var letterSpacing: Double = 1.0 { didSet { textView.letterSpacing = letterSpacing + highlighter?.invalidate() } } @@ -124,8 +139,16 @@ public class TextViewController: NSViewController { } } - internal var textStorage: NSTextStorage - internal var storageDelegate: MultiStorageDelegate! + /// Passthrough value for the `textView`s string + public var text: String { + get { + textView.string + } + set { + self.setText(newValue) + } + } + internal var highlighter: Highlighter? private var fontCharWidth: CGFloat { (" " as NSString).size(withAttributes: [.font: font]).width } @@ -158,7 +181,7 @@ public class TextViewController: NSViewController { indentOption: IndentOption, lineHeight: CGFloat, wrapLines: Bool, - cursorPosition: Binding<(Int, Int)>, + cursorPositions: [CursorPosition], editorOverscroll: CGFloat, useThemeBackground: Bool, highlightProvider: HighlightProviding?, @@ -169,7 +192,6 @@ public class TextViewController: NSViewController { bracketPairHighlight: BracketPairHighlight?, undoManager: CEUndoManager? = nil ) { - self.textStorage = NSTextStorage(string: string) self.language = language self.font = font self.theme = theme @@ -177,7 +199,7 @@ public class TextViewController: NSViewController { self.indentOption = indentOption self.lineHeightMultiple = lineHeight self.wrapLines = wrapLines - self.cursorPosition = cursorPosition + self.cursorPositions = cursorPositions self.editorOverscroll = editorOverscroll self.useThemeBackground = useThemeBackground self.highlightProvider = highlightProvider @@ -188,9 +210,19 @@ public class TextViewController: NSViewController { self.bracketPairHighlight = bracketPairHighlight self._undoManager = undoManager - self.storageDelegate = MultiStorageDelegate() - super.init(nibName: nil, bundle: nil) + + self.textView = TextView( + string: string, + font: font, + textColor: theme.text, + lineHeightMultiplier: lineHeightMultiple, + wrapLines: wrapLines, + isEditable: isEditable, + isSelectable: isSelectable, + letterSpacing: letterSpacing, + delegate: self + ) } required init?(coder: NSCoder) { @@ -202,6 +234,7 @@ public class TextViewController: NSViewController { public func setText(_ text: String) { self.textView.setText(text) self.setUpHighlighter() + self.gutterView.setNeedsDisplay(self.gutterView.frame) } // MARK: Paragraph Style @@ -257,9 +290,11 @@ public class TextViewController: NSViewController { } deinit { + if let highlighter { + textView.removeStorageDelegate(highlighter) + } highlighter = nil highlightProvider = nil - storageDelegate = nil NotificationCenter.default.removeObserver(self) cancellables.forEach { $0.cancel() } } diff --git a/Sources/CodeEditTextView/Gutter/GutterView.swift b/Sources/CodeEditTextView/Gutter/GutterView.swift index 3abb9f8c0..6d02aade4 100644 --- a/Sources/CodeEditTextView/Gutter/GutterView.swift +++ b/Sources/CodeEditTextView/Gutter/GutterView.swift @@ -194,12 +194,14 @@ public class GutterView: NSView { guard let context = NSGraphicsContext.current?.cgContext else { return } + CATransaction.begin() superview?.clipsToBounds = false superview?.layer?.masksToBounds = false layer?.backgroundColor = backgroundColor?.cgColor updateWidthIfNeeded() drawSelectedLines(context) drawLineNumbers(context) + CATransaction.commit() } deinit { diff --git a/Sources/CodeEditTextView/TextViewCoordinator.swift b/Sources/CodeEditTextView/TextViewCoordinator.swift new file mode 100644 index 000000000..c59685d2e --- /dev/null +++ b/Sources/CodeEditTextView/TextViewCoordinator.swift @@ -0,0 +1,41 @@ +// +// TextViewCoordinator.swift +// CodeEditTextView +// +// Created by Khan Winter on 11/14/23. +// + +import AppKit +import CodeEditInputView + +/// # TextViewCoordinator +/// +/// A protocol that can be used to provide extra functionality to ``CodeEditTextView/CodeEditTextView`` while avoiding +/// some of the inefficiencies of SwiftUI. +/// +public protocol TextViewCoordinator: AnyObject { + /// Called when an instance of ``TextViewController`` is available. Use this method to install any delegates, + /// perform any modifications on the text view or controller, or capture the text view for later use in your app. + /// + /// - Parameter controller: The text controller. This is safe to keep a weak reference to, as long as it is + /// dereferenced when ``TextViewCoordinator/destroy()-49rej`` is called. + func prepareCoordinator(controller: TextViewController) + + /// Called when the text view's text changed. + /// - Parameter controller: The text controller. + func textViewDidChangeText(controller: TextViewController) + + /// Called after the text view updated it's cursor positions. + /// - Parameter newPositions: The new positions of the cursors. + func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) + + /// Called when the text controller is being destroyed. Use to free any necessary resources. + func destroy() +} + +/// Default implementations +public extension TextViewCoordinator { + func textViewDidChangeText(controller: TextViewController) { } + func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) { } + func destroy() { } +} diff --git a/Tests/CodeEditTextViewTests/TextViewControllerTests.swift b/Tests/CodeEditTextViewTests/TextViewControllerTests.swift index f9159c634..ce7f11e9a 100644 --- a/Tests/CodeEditTextViewTests/TextViewControllerTests.swift +++ b/Tests/CodeEditTextViewTests/TextViewControllerTests.swift @@ -31,7 +31,6 @@ final class TextViewControllerTests: XCTestCase { comments: .systemGreen ) controller = TextViewController( - string: "", language: .default, font: .monospacedSystemFont(ofSize: 11, weight: .medium), theme: theme, @@ -39,7 +38,7 @@ final class TextViewControllerTests: XCTestCase { indentOption: .spaces(count: 4), lineHeight: 1.0, wrapLines: true, - cursorPosition: .constant((1, 1)), + cursorPositions: [], editorOverscroll: 0.5, useThemeBackground: true, highlightProvider: nil, @@ -53,6 +52,8 @@ final class TextViewControllerTests: XCTestCase { controller.loadView() } + // MARK: Capture Names + func test_captureNames() throws { // test for "keyword" let captureName1 = "keyword" @@ -77,6 +78,8 @@ final class TextViewControllerTests: XCTestCase { XCTAssertEqual(color4, NSColor.textColor) } + // MARK: Overscroll + func test_editorOverScroll() throws { let scrollView = try XCTUnwrap(controller.view as? NSScrollView) scrollView.frame = .init(x: .zero, @@ -104,6 +107,8 @@ final class TextViewControllerTests: XCTestCase { XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 87.0) } + // MARK: Insets + func test_editorInsets() throws { let scrollView = try XCTUnwrap(controller.view as? NSScrollView) scrollView.frame = .init(x: .zero, @@ -154,6 +159,8 @@ final class TextViewControllerTests: XCTestCase { XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 0) } + // MARK: Indent + func test_indentOptionString() { XCTAssertEqual(" ", IndentOption.spaces(count: 1).stringValue) XCTAssertEqual(" ", IndentOption.spaces(count: 2).stringValue) @@ -223,34 +230,36 @@ final class TextViewControllerTests: XCTestCase { controller.letterSpacing = 1.0 } + // MARK: Bracket Highlights + func test_bracketHighlights() { controller.viewDidLoad() controller.bracketPairHighlight = nil controller.textView.string = "{ Loren Ipsum {} }" - controller.setCursorPosition((1, 2)) // After first opening { + controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { XCTAssert(controller.highlightLayers.isEmpty, "Controller added highlight layer when setting is set to `nil`") - controller.setCursorPosition((1, 3)) + controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) controller.bracketPairHighlight = .bordered(color: .black) controller.textView.setNeedsDisplay() - controller.setCursorPosition((1, 2)) // After first opening { + controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { XCTAssert(controller.highlightLayers.count == 2, "Controller created an incorrect number of layers for bordered. Expected 2, found \(controller.highlightLayers.count)") - controller.setCursorPosition((1, 3)) + controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") controller.bracketPairHighlight = .underline(color: .black) - controller.setCursorPosition((1, 2)) // After first opening { + controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { XCTAssert(controller.highlightLayers.count == 2, "Controller created an incorrect number of layers for underline. Expected 2, found \(controller.highlightLayers.count)") - controller.setCursorPosition((1, 3)) + controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") controller.bracketPairHighlight = .flash - controller.setCursorPosition((1, 2)) // After first opening { + controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { XCTAssert(controller.highlightLayers.count == 1, "Controller created more than one layer for flash animation. Expected 1, found \(controller.highlightLayers.count)") - controller.setCursorPosition((1, 3)) + controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") - controller.setCursorPosition((1, 2)) // After first opening { + controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { XCTAssert(controller.highlightLayers.count == 1, "Controller created more than one layer for flash animation. Expected 1, found \(controller.highlightLayers.count)") let exp = expectation(description: "Test after 0.8 seconds") let result = XCTWaiter.wait(for: [exp], timeout: 0.8) @@ -293,5 +302,106 @@ final class TextViewControllerTests: XCTestCase { idx = controller.findClosingPair("}", "{", from: 17, limit: 0, reverse: true) XCTAssert(idx == nil, "Walking backwards with missing pair failed. Expected `nil`, found: `\(String(describing: idx))`") } + + // MARK: Set Text + + func test_setText() { + controller.textView.string = "Hello World" + controller.textView.selectionManager.setSelectedRange(NSRange(location: 1, length: 2)) + + controller.setText("\nHello World with newline!") + + XCTAssert(controller.string == "\nHello World with newline!") + XCTAssertEqual(controller.cursorPositions.count, 1) + XCTAssertEqual(controller.cursorPositions[0].line, 2) + XCTAssertEqual(controller.cursorPositions[0].column, 1) + XCTAssertEqual(controller.cursorPositions[0].range.location, 1) + XCTAssertEqual(controller.cursorPositions[0].range.length, 2) + XCTAssertEqual(controller.textView.selectionManager.textSelections.count, 1) + XCTAssertEqual(controller.textView.selectionManager.textSelections[0].range.location, 1) + XCTAssertEqual(controller.textView.selectionManager.textSelections[0].range.length, 2) + } + + // MARK: Cursor Positions + + func test_cursorPositionRangeInit() { + controller.setText("Hello World") + + // Test adding a position returns a valid one + controller.setCursorPositions([CursorPosition(range: NSRange(location: 0, length: 5))]) + XCTAssertEqual(controller.cursorPositions.count, 1) + XCTAssertEqual(controller.cursorPositions[0].range.location, 0) + XCTAssertEqual(controller.cursorPositions[0].range.length, 5) + XCTAssertEqual(controller.cursorPositions[0].line, 1) + XCTAssertEqual(controller.cursorPositions[0].column, 1) + + // Test an invalid position is ignored + controller.setCursorPositions([CursorPosition(range: NSRange(location: -1, length: 25))]) + XCTAssertTrue(controller.cursorPositions.count == 0) + + // Test that column and line are correct + controller.setText("1\n2\n3\n4\n") + controller.setCursorPositions([CursorPosition(range: NSRange(location: 2, length: 0))]) + XCTAssertEqual(controller.cursorPositions.count, 1) + XCTAssertEqual(controller.cursorPositions[0].range.location, 2) + XCTAssertEqual(controller.cursorPositions[0].range.length, 0) + XCTAssertEqual(controller.cursorPositions[0].line, 2) + XCTAssertEqual(controller.cursorPositions[0].column, 1) + + // Test order and validity of multiple positions. + controller.setCursorPositions([ + CursorPosition(range: NSRange(location: 2, length: 0)), + CursorPosition(range: NSRange(location: 5, length: 1)) + ]) + XCTAssertEqual(controller.cursorPositions.count, 2) + XCTAssertEqual(controller.cursorPositions[0].range.location, 2) + XCTAssertEqual(controller.cursorPositions[0].range.length, 0) + XCTAssertEqual(controller.cursorPositions[0].line, 2) + XCTAssertEqual(controller.cursorPositions[0].column, 1) + XCTAssertEqual(controller.cursorPositions[1].range.location, 5) + XCTAssertEqual(controller.cursorPositions[1].range.length, 1) + XCTAssertEqual(controller.cursorPositions[1].line, 3) + XCTAssertEqual(controller.cursorPositions[1].column, 2) + } + + func test_cursorPositionRowColInit() { + controller.setText("Hello World") + + // Test adding a position returns a valid one + controller.setCursorPositions([CursorPosition(line: 1, column: 1)]) + XCTAssertEqual(controller.cursorPositions.count, 1) + XCTAssertEqual(controller.cursorPositions[0].range.location, 0) + XCTAssertEqual(controller.cursorPositions[0].range.length, 0) + XCTAssertEqual(controller.cursorPositions[0].line, 1) + XCTAssertEqual(controller.cursorPositions[0].column, 1) + + // Test an invalid position is ignored + controller.setCursorPositions([CursorPosition(line: -1, column: 10)]) + XCTAssertTrue(controller.cursorPositions.count == 0) + + // Test that column and line are correct + controller.setText("1\n2\n3\n4\n") + controller.setCursorPositions([CursorPosition(line: 2, column: 1)]) + XCTAssertEqual(controller.cursorPositions.count, 1) + XCTAssertEqual(controller.cursorPositions[0].range.location, 2) + XCTAssertEqual(controller.cursorPositions[0].range.length, 0) + XCTAssertEqual(controller.cursorPositions[0].line, 2) + XCTAssertEqual(controller.cursorPositions[0].column, 1) + + // Test order and validity of multiple positions. + controller.setCursorPositions([ + CursorPosition(line: 1, column: 1), + CursorPosition(line: 3, column: 1) + ]) + XCTAssertEqual(controller.cursorPositions.count, 2) + XCTAssertEqual(controller.cursorPositions[0].range.location, 0) + XCTAssertEqual(controller.cursorPositions[0].range.length, 0) + XCTAssertEqual(controller.cursorPositions[0].line, 1) + XCTAssertEqual(controller.cursorPositions[0].column, 1) + XCTAssertEqual(controller.cursorPositions[1].range.location, 4) + XCTAssertEqual(controller.cursorPositions[1].range.length, 0) + XCTAssertEqual(controller.cursorPositions[1].line, 3) + XCTAssertEqual(controller.cursorPositions[1].column, 1) + } } // swiftlint:enable all From 7cf6937f19fac278751051275d7b3c8c3bcec030 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 18 Nov 2023 08:56:09 -0600 Subject: [PATCH 71/75] Remove `internal`, Unnecessary Public Extensions --- .../Extensions/NSTextStorage+getLine.swift | 4 ++-- .../TextLayoutManager/TextLayoutManager.swift | 10 +++++----- .../TextLineStorage+Node.swift | 14 ++++++------- .../TextLineStorage+Structs.swift | 8 ++++---- .../TextLineStorage/TextLineStorage.swift | 2 +- .../TextSelectionManager+Update.swift | 2 +- .../TextSelectionManager.swift | 20 +++++++++---------- .../TextView/TextView+Drag.swift | 2 +- .../TextView/TextView+Setup.swift | 4 ++-- .../CodeEditInputView/TextView/TextView.swift | 8 ++++---- 10 files changed, 37 insertions(+), 37 deletions(-) diff --git a/Sources/CodeEditInputView/Extensions/NSTextStorage+getLine.swift b/Sources/CodeEditInputView/Extensions/NSTextStorage+getLine.swift index cfc0bf9f4..b7256d565 100644 --- a/Sources/CodeEditInputView/Extensions/NSTextStorage+getLine.swift +++ b/Sources/CodeEditInputView/Extensions/NSTextStorage+getLine.swift @@ -8,7 +8,7 @@ import AppKit extension NSString { - public func getNextLine(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 @@ -22,7 +22,7 @@ extension NSString { } extension NSTextStorage { - public func getNextLine(startingAt location: Int) -> NSRange? { + func getNextLine(startingAt location: Int) -> NSRange? { (self.string as NSString).getNextLine(startingAt: location) } } diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift index f668029b4..a7ae3cb63 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager.swift @@ -74,13 +74,13 @@ public class TextLayoutManager: NSObject { /// The calculated maximum width of all laid out lines. /// - Note: This does not indicate *the* maximum width of the text view if all lines have not been laid out. /// This will be updated if it comes across a wider line. - internal var maxLineWidth: CGFloat = 0 { + var maxLineWidth: CGFloat = 0 { didSet { delegate?.layoutManagerMaxWidthDidChange(newWidth: maxLineWidth + edgeInsets.horizontal) } } /// The maximum width available to lay out lines in. - internal var maxLineLayoutWidth: CGFloat { + var maxLineLayoutWidth: CGFloat { wrapLines ? (delegate?.textViewportSize().width ?? .greatestFiniteMagnitude) - edgeInsets.horizontal : .greatestFiniteMagnitude } @@ -119,7 +119,7 @@ public class TextLayoutManager: NSObject { /// Prepares the layout manager for use. /// Parses the text storage object into lines and builds the `lineStorage` object from those lines. - internal func prepareTextLines() { + func prepareTextLines() { guard lineStorage.count == 0, let textStorage else { return } #if DEBUG // Grab some performance information if debugging. @@ -141,7 +141,7 @@ public class TextLayoutManager: NSObject { } /// Resets the layout manager to an initial state. - internal func reset() { + func reset() { lineStorage.removeAll() visibleLineIds.removeAll() viewReuseQueue.queuedViews.removeAll() @@ -229,7 +229,7 @@ public class TextLayoutManager: NSObject { // MARK: - Layout /// Lays out all visible lines - internal func layoutLines() { // swiftlint:disable:this function_body_length + func layoutLines() { // swiftlint:disable:this function_body_length guard let visibleRect = delegate?.visibleRect, !isInTransaction, let textStorage else { return } CATransaction.begin() let minY = max(visibleRect.minY, 0) diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift index b05622dbf..745cfec3a 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Node.swift @@ -8,11 +8,11 @@ import Foundation extension TextLineStorage { - internal func isRightChild(_ node: Node) -> Bool { + func isRightChild(_ node: Node) -> Bool { node.parent?.right === node } - internal func isLeftChild(_ node: Node) -> Bool { + func isLeftChild(_ node: Node) -> Bool { node.parent?.left === node } @@ -34,7 +34,7 @@ extension TextLineStorage { /// - Parameters: /// - nodeU: The node to replace. /// - nodeV: The node to insert in place of `nodeU` - internal func transplant(_ nodeU: borrowing Node, with nodeV: Node?) { + func transplant(_ nodeU: borrowing Node, with nodeV: Node?) { if nodeU.parent == nil { root = nodeV } else if isLeftChild(nodeU) { @@ -105,7 +105,7 @@ extension TextLineStorage { ) } - internal func sibling() -> Node? { + func sibling() -> Node? { if parent?.left === self { return parent?.right } else { @@ -113,7 +113,7 @@ extension TextLineStorage { } } - internal func minimum() -> Node { + func minimum() -> Node { if let left { return left.minimum() } else { @@ -121,7 +121,7 @@ extension TextLineStorage { } } - internal func maximum() -> Node { + func maximum() -> Node { if let right { return right.maximum() } else { @@ -129,7 +129,7 @@ extension TextLineStorage { } } - internal 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/CodeEditInputView/TextLineStorage/TextLineStorage+Structs.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Structs.swift index 65dd30e6a..c98f3dd54 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Structs.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage+Structs.swift @@ -9,7 +9,7 @@ import Foundation extension TextLineStorage where Data: Identifiable { public struct TextLinePosition { - internal init(data: Data, range: NSRange, yPos: CGFloat, height: CGFloat, index: Int) { + 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 } - internal init(position: NodePosition) { + init(position: NodePosition) { self.data = position.node.data self.range = NSRange(location: position.textPos, length: position.node.length) self.yPos = position.yPos @@ -37,7 +37,7 @@ extension TextLineStorage where Data: Identifiable { public let index: Int } - internal struct NodePosition { + 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 @@ -48,7 +48,7 @@ extension TextLineStorage where Data: Identifiable { let index: Int } - internal struct NodeSubtreeMetadata { + struct NodeSubtreeMetadata { let height: CGFloat let offset: Int let count: Int diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift index cd4ded0fc..273362364 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift @@ -30,7 +30,7 @@ public final class TextLineStorage { case none } - internal var root: Node? + var root: Node? /// The number of characters in the storage object. private(set) public var length: Int = 0 diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift index f8aa90d8b..f0b1fffe4 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager+Update.swift @@ -39,7 +39,7 @@ extension TextSelectionManager { } } - internal func notifyAfterEdit() { + func notifyAfterEdit() { updateSelectionViews() NotificationCenter.default.post(Notification(name: Self.selectionChangedNotification, object: self)) } diff --git a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift index 8b49701be..8f766ff8d 100644 --- a/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditInputView/TextSelectionManager/TextSelectionManager.swift @@ -23,11 +23,11 @@ public class TextSelectionManager: NSObject { public class TextSelection: Hashable, Equatable { public var range: NSRange - internal weak var view: CursorView? - internal var boundingRect: CGRect = .zero - internal var suggestedXPos: CGFloat? + weak var view: CursorView? + var boundingRect: CGRect = .zero + var suggestedXPos: CGFloat? /// The position this selection should 'rotate' around when modifying selections. - internal var pivot: Int? + var pivot: Int? init(range: NSRange, view: CursorView? = nil) { self.range = range @@ -79,10 +79,10 @@ public class TextSelectionManager: NSObject { public var selectionBackgroundColor: NSColor = NSColor.selectedTextBackgroundColor internal(set) public var textSelections: [TextSelection] = [] - internal weak var layoutManager: TextLayoutManager? - internal weak var textStorage: NSTextStorage? - internal weak var layoutView: NSView? - internal weak var delegate: TextSelectionManagerDelegate? + weak var layoutManager: TextLayoutManager? + weak var textStorage: NSTextStorage? + weak var layoutView: NSView? + weak var delegate: TextSelectionManagerDelegate? init( layoutManager: TextLayoutManager, @@ -183,7 +183,7 @@ public class TextSelectionManager: NSObject { } } - internal func removeCursors() { + func removeCursors() { for textSelection in textSelections { textSelection.view?.removeFromSuperview() } @@ -193,7 +193,7 @@ public class TextSelectionManager: NSObject { /// 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) { + func drawSelections(in rect: NSRect) { guard let context = NSGraphicsContext.current?.cgContext else { return } context.saveGState() var highlightedLines: Set = [] diff --git a/Sources/CodeEditInputView/TextView/TextView+Drag.swift b/Sources/CodeEditInputView/TextView/TextView+Drag.swift index ab6c4d5d1..171e0c4ab 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Drag.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Drag.swift @@ -26,7 +26,7 @@ extension TextView: NSDraggingSource { } } - internal func setUpDragGesture() { + func setUpDragGesture() { let dragGesture = DragSelectionGesture(target: self, action: #selector(dragGestureHandler(_:))) dragGesture.minimumPressDuration = NSEvent.doubleClickInterval / 3 dragGesture.isEnabled = isSelectable diff --git a/Sources/CodeEditInputView/TextView/TextView+Setup.swift b/Sources/CodeEditInputView/TextView/TextView+Setup.swift index 003304591..7eea47bff 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Setup.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Setup.swift @@ -8,7 +8,7 @@ import AppKit extension TextView { - internal func setUpLayoutManager(lineHeightMultiplier: CGFloat, wrapLines: Bool) -> TextLayoutManager { + func setUpLayoutManager(lineHeightMultiplier: CGFloat, wrapLines: Bool) -> TextLayoutManager { TextLayoutManager( textStorage: textStorage, lineHeightMultiplier: lineHeightMultiplier, @@ -18,7 +18,7 @@ extension TextView { ) } - internal func setUpSelectionManager() -> TextSelectionManager { + func setUpSelectionManager() -> TextSelectionManager { TextSelectionManager( layoutManager: layoutManager, textStorage: textStorage, diff --git a/Sources/CodeEditInputView/TextView/TextView.swift b/Sources/CodeEditInputView/TextView/TextView.swift index 99f3fdb1f..e54274fd1 100644 --- a/Sources/CodeEditInputView/TextView/TextView.swift +++ b/Sources/CodeEditInputView/TextView/TextView.swift @@ -195,9 +195,9 @@ public class TextView: NSView, NSTextContent { // MARK: - Private Properties - internal var isFirstResponder: Bool = false - internal var mouseDragAnchor: CGPoint? - internal var mouseDragTimer: Timer? + var isFirstResponder: Bool = false + var mouseDragAnchor: CGPoint? + var mouseDragTimer: Timer? private var fontCharWidth: CGFloat { (" " as NSString).size(withAttributes: [.font: font]).width @@ -211,7 +211,7 @@ public class TextView: NSView, NSTextContent { return enclosingScrollView } - internal var storageDelegate: MultiStorageDelegate! + var storageDelegate: MultiStorageDelegate! // MARK: - Init From 7ca798d5075c522b6745fed55600fb10bc0c671e Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 18 Nov 2023 08:59:03 -0600 Subject: [PATCH 72/75] Update TextViewControllerTests.swift --- Tests/CodeEditTextViewTests/TextViewControllerTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/CodeEditTextViewTests/TextViewControllerTests.swift b/Tests/CodeEditTextViewTests/TextViewControllerTests.swift index ce7f11e9a..b9184e398 100644 --- a/Tests/CodeEditTextViewTests/TextViewControllerTests.swift +++ b/Tests/CodeEditTextViewTests/TextViewControllerTests.swift @@ -31,6 +31,7 @@ final class TextViewControllerTests: XCTestCase { comments: .systemGreen ) controller = TextViewController( + string: "", language: .default, font: .monospacedSystemFont(ofSize: 11, weight: .medium), theme: theme, From 2b6ae13a1924eddd655862a84fb9753750187754 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 18 Nov 2023 09:12:01 -0600 Subject: [PATCH 73/75] Explicit frame size in test --- .../Controller/TextViewController+HighlightBracket.swift | 7 +++++++ Tests/CodeEditTextViewTests/TextViewControllerTests.swift | 1 + 2 files changed, 8 insertions(+) diff --git a/Sources/CodeEditTextView/Controller/TextViewController+HighlightBracket.swift b/Sources/CodeEditTextView/Controller/TextViewController+HighlightBracket.swift index 207f10b6c..65f72e91d 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController+HighlightBracket.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController+HighlightBracket.swift @@ -55,6 +55,12 @@ extension TextViewController { } } + + /// # Dev Note + /// It's interesting to note that this problem could trivially be turned into a monoid, and the locations of each + /// pair start/end location determined when the view is loaded. It could then be parallelized for initial speed + /// and this lookup would be much faster. + /// Finds a closing character given a pair of characters, ignores pairs inside the given pair. /// /// ```pseudocode @@ -63,6 +69,7 @@ extension TextViewController { /// } -- A naive algorithm may find this character as the closing pair, which would be incorrect. /// } -- Found /// ``` + /// /// - Parameters: /// - open: The opening pair to look for. /// - close: The closing pair to look for. diff --git a/Tests/CodeEditTextViewTests/TextViewControllerTests.swift b/Tests/CodeEditTextViewTests/TextViewControllerTests.swift index b9184e398..7ae5c49fe 100644 --- a/Tests/CodeEditTextViewTests/TextViewControllerTests.swift +++ b/Tests/CodeEditTextViewTests/TextViewControllerTests.swift @@ -234,6 +234,7 @@ final class TextViewControllerTests: XCTestCase { // MARK: Bracket Highlights func test_bracketHighlights() { + controller.scrollView.setFrameSize(NSSize(width: 500, height: 500)) controller.viewDidLoad() controller.bracketPairHighlight = nil controller.textView.string = "{ Loren Ipsum {} }" From 22dfab214ef5c9032c88774441a3e46b4a75129c Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 18 Nov 2023 09:12:51 -0600 Subject: [PATCH 74/75] Linter --- .../Controller/TextViewController+HighlightBracket.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/CodeEditTextView/Controller/TextViewController+HighlightBracket.swift b/Sources/CodeEditTextView/Controller/TextViewController+HighlightBracket.swift index 65f72e91d..16c8e4958 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController+HighlightBracket.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController+HighlightBracket.swift @@ -55,7 +55,6 @@ extension TextViewController { } } - /// # Dev Note /// It's interesting to note that this problem could trivially be turned into a monoid, and the locations of each /// pair start/end location determined when the view is loaded. It could then be parallelized for initial speed From afcafa9b6aefdfa146b4fcbe57cee25f8639a2af Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Sat, 9 Dec 2023 10:59:37 -0600 Subject: [PATCH 75/75] Update Sources/CodeEditInputView/TextView/TextView+Move.swift Co-authored-by: Wesley de Groot --- Sources/CodeEditInputView/TextView/TextView+Move.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditInputView/TextView/TextView+Move.swift b/Sources/CodeEditInputView/TextView/TextView+Move.swift index eed1f9ab3..7beb92310 100644 --- a/Sources/CodeEditInputView/TextView/TextView+Move.swift +++ b/Sources/CodeEditInputView/TextView/TextView+Move.swift @@ -1,5 +1,5 @@ // -// File.swift +// TextView+Move.swift // // // Created by Khan Winter on 9/10/23.