Skip to content

Commit

Permalink
Implement the Emacs Kill Ring (#35)
Browse files Browse the repository at this point in the history
  • Loading branch information
thecoolwinter authored Jun 13, 2024
1 parent 80911be commit e95fbfb
Show file tree
Hide file tree
Showing 6 changed files with 170 additions and 10 deletions.
3 changes: 3 additions & 0 deletions Sources/CodeEditTextView/TextView/TextView+Delete.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ extension TextView {
guard extendedRange.location >= 0 else { continue }
textSelection.range.formUnion(extendedRange)
}
KillRing.shared.kill(
strings: selectionManager.textSelections.map(\.range).compactMap({ textStorage.substring(from: $0) })
)
replaceCharacters(in: selectionManager.textSelections.map(\.range), with: "")
unmarkTextIfNeeded()
}
Expand Down
31 changes: 31 additions & 0 deletions Sources/CodeEditTextView/TextView/TextView+Insert.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,35 @@ extension TextView {
override public func insertTab(_ sender: Any?) {
insertText("\t")
}

override public func yank(_ sender: Any?) {
let strings = KillRing.shared.yank()
insertMultipleString(strings)
}

/// Not documented or in any headers, but required if kill ring size > 1.
/// From Cocoa docs: "note that yankAndSelect: is not listed in any headers"
@objc func yankAndSelect(_ sender: Any?) {
let strings = KillRing.shared.yankAndSelect()
insertMultipleString(strings)
}

private func insertMultipleString(_ strings: [String]) {
let selectedRanges = selectionManager.textSelections.map(\.range)

guard selectedRanges.count > 0 else { return }

for idx in (0..<selectedRanges.count).reversed() {
guard idx < strings.count else { break }
let range = selectedRanges[idx]

if idx == selectedRanges.count - 1 && idx != strings.count - 1 {
// Last range, still have strings remaining. Concatenate them.
let remainingString = strings[idx..<strings.count].joined(separator: "\n")
replaceCharacters(in: range, with: remainingString)
} else {
replaceCharacters(in: range, with: strings[idx])
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ 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
Expand Down
16 changes: 8 additions & 8 deletions Sources/CodeEditTextView/TextView/TextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -253,15 +253,15 @@ public class TextView: NSView, NSTextContent {
/// - delegate: The text view's delegate.
public init(
string: String,
font: NSFont,
textColor: NSColor,
lineHeightMultiplier: CGFloat,
wrapLines: Bool,
isEditable: Bool,
isSelectable: Bool,
letterSpacing: Double,
font: NSFont = .monospacedSystemFont(ofSize: 12, weight: .regular),
textColor: NSColor = .labelColor,
lineHeightMultiplier: CGFloat = 1.0,
wrapLines: Bool = true,
isEditable: Bool = true,
isSelectable: Bool = true,
letterSpacing: Double = 1.0,
useSystemCursor: Bool = false,
delegate: TextViewDelegate
delegate: TextViewDelegate? = nil
) {
self.textStorage = NSTextStorage(string: string)
self.delegate = delegate
Expand Down
55 changes: 55 additions & 0 deletions Sources/CodeEditTextView/Utils/KillRing.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//
// KillRing.swift
// CodeEditTextView
//
// Created by Khan Winter on 6/13/24.
//

import Foundation

// swiftlint:disable line_length

/// A global kill ring similar to emacs. With support for killing and yanking multiple cursors.
///
/// Documentation sources:
/// - [Emacs kill ring](https://www.gnu.org/software/emacs/manual/html_node/emacs/Yanking.html)
/// - [Cocoa Docs](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/EventOverview/TextDefaultsBindings/TextDefaultsBindings.html)
class KillRing {
static let shared: KillRing = KillRing()

// swiftlint:enable line_length

private static let bufferSizeKey = "NSTextKillRingSize"

private var buffer: [[String]]
private var index = 0

init(_ size: Int? = nil) {
buffer = Array(
repeating: [""],
count: size ?? max(1, UserDefaults.standard.integer(forKey: Self.bufferSizeKey))
)
}

/// Performs the kill action in response to a delete action. Saving the deleted text to the kill ring.
func kill(strings: [String]) {
incrementIndex()
buffer[index] = strings
}

/// Yanks the current item in the ring.
func yank() -> [String] {
return buffer[index]
}

/// Yanks an item from the ring, and selects the next one in the ring.
func yankAndSelect() -> [String] {
let retVal = buffer[index]
incrementIndex()
return retVal
}

private func incrementIndex() {
index = (index + 1) % buffer.count
}
}
73 changes: 73 additions & 0 deletions Tests/CodeEditTextViewTests/KillRingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import XCTest
@testable import CodeEditTextView

class KillRingTests: XCTestCase {
func test_killRingYank() {
var ring = KillRing.shared
ring.kill(strings: ["hello"])
for _ in 0..<100 {
XCTAssertEqual(ring.yank(), ["hello"])
}

ring.kill(strings: ["hello", "multiple", "strings"])
// should never change on yank
for _ in 0..<100 {
XCTAssertEqual(ring.yank(), ["hello", "multiple", "strings"])
}

ring = KillRing(2)
ring.kill(strings: ["hello"])
for _ in 0..<100 {
XCTAssertEqual(ring.yank(), ["hello"])
}

ring.kill(strings: ["hello", "multiple", "strings"])
// should never change on yank
for _ in 0..<100 {
XCTAssertEqual(ring.yank(), ["hello", "multiple", "strings"])
}
}

func test_killRingYankAndSelect() {
let ring = KillRing(5)
ring.kill(strings: ["1"])
ring.kill(strings: ["2"])
ring.kill(strings: ["3", "3", "3"])
ring.kill(strings: ["4", "4"])
ring.kill(strings: ["5"])
// should loop
for _ in 0..<5 {
XCTAssertEqual(ring.yankAndSelect(), ["5"])
XCTAssertEqual(ring.yankAndSelect(), ["1"])
XCTAssertEqual(ring.yankAndSelect(), ["2"])
XCTAssertEqual(ring.yankAndSelect(), ["3", "3", "3"])
XCTAssertEqual(ring.yankAndSelect(), ["4", "4"])
}
}

func test_textViewYank() {
let view = TextView(string: "Hello World")
view.selectionManager.setSelectedRange(NSRange(location: 0, length: 1))
view.delete(self)
XCTAssertEqual(view.string, "ello World")

view.yank(self)
XCTAssertEqual(view.string, "Hello World")
view.selectionManager.setSelectedRange(NSRange(location: 0, length: 0))
view.yank(self)
XCTAssertEqual(view.string, "HHello World")
}

func test_textViewYankMultipleCursors() {
let view = TextView(string: "Hello World")
view.selectionManager.setSelectedRanges([NSRange(location: 1, length: 0), NSRange(location: 4, length: 0)])
view.delete(self)
XCTAssertEqual(view.string, "elo World")

view.yank(self)
XCTAssertEqual(view.string, "Hello World")
view.selectionManager.setSelectedRanges([NSRange(location: 0, length: 0)])
view.yank(self)
XCTAssertEqual(view.string, "H\nlHello World")
}
}

0 comments on commit e95fbfb

Please sign in to comment.