Skip to content
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
49 changes: 49 additions & 0 deletions Sources/STTextViewAppKit/STTextView+Mark.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Created by Marcin Krzyzanowski
// https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md

import AppKit

extension STTextView {

/// The mark is a saved selection that later commands can select to, swap with, or kill to.
/// Together with `yank(_:)` it completes the Emacs-style command family from
/// `NSStandardKeyBindingResponding`, which multi-step `DefaultKeyBinding.dict`
/// macros (line moves, paragraph cut/copy, …) are commonly built on.
///
/// https://www.gnu.org/software/emacs/manual/html_node/emacs/Setting-Mark.html
override open func setMark(_ sender: Any?) {
_mark = textSelection
}

/// Exchanges the current selection with the mark, so the two locations can be toggled between.
override open func swapWithMark(_ sender: Any?) {
guard let mark = clampedMark() else { return }
_mark = textSelection
textSelection = mark
}

/// Extends the selection to cover everything between the mark and the current selection.
override open func selectToMark(_ sender: Any?) {
guard let mark = clampedMark() else { return }
textSelection = NSUnionRange(mark, textSelection)
}

/// Deletes everything between the mark and the current selection, adding it to the kill ring
/// (routed through `deleteBackward(_:)`, which feeds `_yankingManager`) so a following
/// `yank(_:)` reinserts it.
override open func deleteToMark(_ sender: Any?) {
guard let mark = clampedMark() else { return }
textSelection = NSUnionRange(mark, textSelection)
deleteBackward(sender)
_mark = textSelection
}

/// The mark survives arbitrary edits, so by the time it's used it can point past the current
/// text end (or into it with excess length).
private func clampedMark() -> NSRange? {
guard let mark = _mark else { return nil }
let length = textContentManager.length
let location = min(mark.location, length)
return NSRange(location: location, length: min(mark.length, length - location))
}
}
4 changes: 4 additions & 0 deletions Sources/STTextViewAppKit/STTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,10 @@ open class STTextView: NSView, NSTextInput, NSTextContent, STTextViewProtocol {
var _undoManager: UndoManager?
var _yankingManager = YankingManager()

/// The Emacs-style mark set by `setMark(_:)` and consumed by
/// `selectToMark(_:)`, `swapWithMark(_:)` and `deleteToMark(_:)`.
var _mark: NSRange?

var markedText: STMarkedText?

/// The attributes used to draw marked text.
Expand Down
78 changes: 78 additions & 0 deletions Tests/STTextViewAppKitTests/MarkTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#if os(macOS)
import XCTest
@testable import STTextViewAppKit

class MarkTests: XCTestCase {

func testSwapWithMark() {
let textView = STTextView()
textView.text = "abc def"

textView.textSelection = NSRange(location: 0, length: 3)
textView.setMark(nil)
textView.textSelection = NSRange(location: 4, length: 3)

textView.swapWithMark(nil)
XCTAssertEqual(textView.textSelection, NSRange(location: 0, length: 3))

textView.swapWithMark(nil)
XCTAssertEqual(textView.textSelection, NSRange(location: 4, length: 3))
}

func testSelectToMark() {
let textView = STTextView()
textView.text = "abc def ghi"

textView.textSelection = NSRange(location: 0, length: 3)
textView.setMark(nil)
textView.textSelection = NSRange(location: 8, length: 0)

textView.selectToMark(nil)
XCTAssertEqual(textView.textSelection, NSRange(location: 0, length: 8))
}

func testDeleteToMarkFeedsKillRing() {
let textView = STTextView()
textView.text = "abc def ghi"

textView.textSelection = NSRange(location: 4, length: 0)
textView.setMark(nil)
textView.textSelection = NSRange(location: 8, length: 0)

textView.deleteToMark(nil)
XCTAssertEqual(textView.text, "abc ghi")

textView.textSelection = NSRange(location: 0, length: 0)
textView.yank(nil)
XCTAssertEqual(textView.text, "def abc ghi")
}

func testStaleMarkIsClampedAfterEdits() {
let textView = STTextView()
textView.text = "abcdefgh"

textView.textSelection = NSRange(location: 8, length: 0)
textView.setMark(nil)
textView.text = "abc"
textView.textSelection = NSRange(location: 0, length: 0)

textView.selectToMark(nil)
XCTAssertEqual(textView.textSelection, NSRange(location: 0, length: 3))
}

func testCommandsWithoutMarkAreNoOps() {
let textView = STTextView()
textView.text = "abc"
textView.textSelection = NSRange(location: 1, length: 0)

textView.swapWithMark(nil)
textView.selectToMark(nil)
textView.deleteToMark(nil)

XCTAssertEqual(textView.text, "abc")
XCTAssertEqual(textView.textSelection, NSRange(location: 1, length: 0))
}

}

#endif
Loading