From 9eedf0d9e1499f8d8aa455b84cb2001f44047398 Mon Sep 17 00:00:00 2001 From: rhsev Date: Thu, 16 Jul 2026 22:21:18 +0200 Subject: [PATCH] Add Emacs-style mark commands (setMark:, selectToMark:, swapWithMark:, deleteToMark:) The kill ring (yank:) is already implemented, but the mark family from NSStandardKeyBindingResponding was missing, so DefaultKeyBinding.dict macros built on setMark:/deleteToMark:/selectToMark: (line moves, paragraph cut/copy) broke mid-chain. deleteToMark: routes through deleteBackward: so the killed text lands in the yank buffer. --- .../STTextViewAppKit/STTextView+Mark.swift | 49 ++++++++++++ Sources/STTextViewAppKit/STTextView.swift | 4 + Tests/STTextViewAppKitTests/MarkTests.swift | 78 +++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 Sources/STTextViewAppKit/STTextView+Mark.swift create mode 100644 Tests/STTextViewAppKitTests/MarkTests.swift diff --git a/Sources/STTextViewAppKit/STTextView+Mark.swift b/Sources/STTextViewAppKit/STTextView+Mark.swift new file mode 100644 index 0000000..8a2464e --- /dev/null +++ b/Sources/STTextViewAppKit/STTextView+Mark.swift @@ -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)) + } +} diff --git a/Sources/STTextViewAppKit/STTextView.swift b/Sources/STTextViewAppKit/STTextView.swift index 94dcbf7..28086b4 100644 --- a/Sources/STTextViewAppKit/STTextView.swift +++ b/Sources/STTextViewAppKit/STTextView.swift @@ -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. diff --git a/Tests/STTextViewAppKitTests/MarkTests.swift b/Tests/STTextViewAppKitTests/MarkTests.swift new file mode 100644 index 0000000..d2c87c7 --- /dev/null +++ b/Tests/STTextViewAppKitTests/MarkTests.swift @@ -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