Skip to content

Commit e95fbfb

Browse files
Implement the Emacs Kill Ring (#35)
1 parent 80911be commit e95fbfb

File tree

6 files changed

+170
-10
lines changed

6 files changed

+170
-10
lines changed

Sources/CodeEditTextView/TextView/TextView+Delete.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ extension TextView {
6060
guard extendedRange.location >= 0 else { continue }
6161
textSelection.range.formUnion(extendedRange)
6262
}
63+
KillRing.shared.kill(
64+
strings: selectionManager.textSelections.map(\.range).compactMap({ textStorage.substring(from: $0) })
65+
)
6366
replaceCharacters(in: selectionManager.textSelections.map(\.range), with: "")
6467
unmarkTextIfNeeded()
6568
}

Sources/CodeEditTextView/TextView/TextView+Insert.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,35 @@ extension TextView {
1515
override public func insertTab(_ sender: Any?) {
1616
insertText("\t")
1717
}
18+
19+
override public func yank(_ sender: Any?) {
20+
let strings = KillRing.shared.yank()
21+
insertMultipleString(strings)
22+
}
23+
24+
/// Not documented or in any headers, but required if kill ring size > 1.
25+
/// From Cocoa docs: "note that yankAndSelect: is not listed in any headers"
26+
@objc func yankAndSelect(_ sender: Any?) {
27+
let strings = KillRing.shared.yankAndSelect()
28+
insertMultipleString(strings)
29+
}
30+
31+
private func insertMultipleString(_ strings: [String]) {
32+
let selectedRanges = selectionManager.textSelections.map(\.range)
33+
34+
guard selectedRanges.count > 0 else { return }
35+
36+
for idx in (0..<selectedRanges.count).reversed() {
37+
guard idx < strings.count else { break }
38+
let range = selectedRanges[idx]
39+
40+
if idx == selectedRanges.count - 1 && idx != strings.count - 1 {
41+
// Last range, still have strings remaining. Concatenate them.
42+
let remainingString = strings[idx..<strings.count].joined(separator: "\n")
43+
replaceCharacters(in: range, with: remainingString)
44+
} else {
45+
replaceCharacters(in: range, with: strings[idx])
46+
}
47+
}
48+
}
1849
}

Sources/CodeEditTextView/TextView/TextView+ReplaceCharacters.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ import AppKit
99
import TextStory
1010

1111
extension TextView {
12-
// MARK: - Replace Characters
13-
1412
/// Replace the characters in the given ranges with the given string.
1513
/// - Parameters:
1614
/// - ranges: The ranges to replace

Sources/CodeEditTextView/TextView/TextView.swift

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -253,15 +253,15 @@ public class TextView: NSView, NSTextContent {
253253
/// - delegate: The text view's delegate.
254254
public init(
255255
string: String,
256-
font: NSFont,
257-
textColor: NSColor,
258-
lineHeightMultiplier: CGFloat,
259-
wrapLines: Bool,
260-
isEditable: Bool,
261-
isSelectable: Bool,
262-
letterSpacing: Double,
256+
font: NSFont = .monospacedSystemFont(ofSize: 12, weight: .regular),
257+
textColor: NSColor = .labelColor,
258+
lineHeightMultiplier: CGFloat = 1.0,
259+
wrapLines: Bool = true,
260+
isEditable: Bool = true,
261+
isSelectable: Bool = true,
262+
letterSpacing: Double = 1.0,
263263
useSystemCursor: Bool = false,
264-
delegate: TextViewDelegate
264+
delegate: TextViewDelegate? = nil
265265
) {
266266
self.textStorage = NSTextStorage(string: string)
267267
self.delegate = delegate
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//
2+
// KillRing.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 6/13/24.
6+
//
7+
8+
import Foundation
9+
10+
// swiftlint:disable line_length
11+
12+
/// A global kill ring similar to emacs. With support for killing and yanking multiple cursors.
13+
///
14+
/// Documentation sources:
15+
/// - [Emacs kill ring](https://www.gnu.org/software/emacs/manual/html_node/emacs/Yanking.html)
16+
/// - [Cocoa Docs](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/EventOverview/TextDefaultsBindings/TextDefaultsBindings.html)
17+
class KillRing {
18+
static let shared: KillRing = KillRing()
19+
20+
// swiftlint:enable line_length
21+
22+
private static let bufferSizeKey = "NSTextKillRingSize"
23+
24+
private var buffer: [[String]]
25+
private var index = 0
26+
27+
init(_ size: Int? = nil) {
28+
buffer = Array(
29+
repeating: [""],
30+
count: size ?? max(1, UserDefaults.standard.integer(forKey: Self.bufferSizeKey))
31+
)
32+
}
33+
34+
/// Performs the kill action in response to a delete action. Saving the deleted text to the kill ring.
35+
func kill(strings: [String]) {
36+
incrementIndex()
37+
buffer[index] = strings
38+
}
39+
40+
/// Yanks the current item in the ring.
41+
func yank() -> [String] {
42+
return buffer[index]
43+
}
44+
45+
/// Yanks an item from the ring, and selects the next one in the ring.
46+
func yankAndSelect() -> [String] {
47+
let retVal = buffer[index]
48+
incrementIndex()
49+
return retVal
50+
}
51+
52+
private func incrementIndex() {
53+
index = (index + 1) % buffer.count
54+
}
55+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import XCTest
2+
@testable import CodeEditTextView
3+
4+
class KillRingTests: XCTestCase {
5+
func test_killRingYank() {
6+
var ring = KillRing.shared
7+
ring.kill(strings: ["hello"])
8+
for _ in 0..<100 {
9+
XCTAssertEqual(ring.yank(), ["hello"])
10+
}
11+
12+
ring.kill(strings: ["hello", "multiple", "strings"])
13+
// should never change on yank
14+
for _ in 0..<100 {
15+
XCTAssertEqual(ring.yank(), ["hello", "multiple", "strings"])
16+
}
17+
18+
ring = KillRing(2)
19+
ring.kill(strings: ["hello"])
20+
for _ in 0..<100 {
21+
XCTAssertEqual(ring.yank(), ["hello"])
22+
}
23+
24+
ring.kill(strings: ["hello", "multiple", "strings"])
25+
// should never change on yank
26+
for _ in 0..<100 {
27+
XCTAssertEqual(ring.yank(), ["hello", "multiple", "strings"])
28+
}
29+
}
30+
31+
func test_killRingYankAndSelect() {
32+
let ring = KillRing(5)
33+
ring.kill(strings: ["1"])
34+
ring.kill(strings: ["2"])
35+
ring.kill(strings: ["3", "3", "3"])
36+
ring.kill(strings: ["4", "4"])
37+
ring.kill(strings: ["5"])
38+
// should loop
39+
for _ in 0..<5 {
40+
XCTAssertEqual(ring.yankAndSelect(), ["5"])
41+
XCTAssertEqual(ring.yankAndSelect(), ["1"])
42+
XCTAssertEqual(ring.yankAndSelect(), ["2"])
43+
XCTAssertEqual(ring.yankAndSelect(), ["3", "3", "3"])
44+
XCTAssertEqual(ring.yankAndSelect(), ["4", "4"])
45+
}
46+
}
47+
48+
func test_textViewYank() {
49+
let view = TextView(string: "Hello World")
50+
view.selectionManager.setSelectedRange(NSRange(location: 0, length: 1))
51+
view.delete(self)
52+
XCTAssertEqual(view.string, "ello World")
53+
54+
view.yank(self)
55+
XCTAssertEqual(view.string, "Hello World")
56+
view.selectionManager.setSelectedRange(NSRange(location: 0, length: 0))
57+
view.yank(self)
58+
XCTAssertEqual(view.string, "HHello World")
59+
}
60+
61+
func test_textViewYankMultipleCursors() {
62+
let view = TextView(string: "Hello World")
63+
view.selectionManager.setSelectedRanges([NSRange(location: 1, length: 0), NSRange(location: 4, length: 0)])
64+
view.delete(self)
65+
XCTAssertEqual(view.string, "elo World")
66+
67+
view.yank(self)
68+
XCTAssertEqual(view.string, "Hello World")
69+
view.selectionManager.setSelectedRanges([NSRange(location: 0, length: 0)])
70+
view.yank(self)
71+
XCTAssertEqual(view.string, "H\nlHello World")
72+
}
73+
}

0 commit comments

Comments
 (0)