Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement the Emacs Kill Ring #35

Merged
merged 1 commit into from
Jun 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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")
}
}
Loading