-
-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
e0aaed2
commit f4e5859
Showing
16 changed files
with
1,516 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,152 @@ | ||
#if canImport(AppKit) | ||
import AppKit | ||
import OSLog | ||
|
||
// I guess this should be defined by AppKit, but isn't | ||
fileprivate let NSOldSelectedCharacterRanges = "NSOldSelectedCharacterRanges" | ||
|
||
/// A minimal `NSTextView` subclass to support correct functionality. | ||
@available(macOS 12.0, *) | ||
open class BaseTextView: NSTextView { | ||
public typealias OnEvent = (_ event: NSEvent, _ action: () -> Void) -> Void | ||
|
||
private let logger = Logger(subsystem: "com.chimehq.SourceView", category: "BaseTextView") | ||
private var activeScrollValue: (NSRange, CGSize)? | ||
private var lastSelectionValue = [NSValue]() | ||
|
||
public var onKeyDown: OnEvent = { $1() } | ||
public var onFlagsChanged: OnEvent = { $1() } | ||
public var onMouseDown: OnEvent = { $1() } | ||
/// Deliver `NSTextView.didChangeSelectionNotification` for all selection changes. | ||
/// | ||
/// See the documenation for `setSelectedRanges(_:affinity:stillSelecting:)` for details. | ||
public var continuousSelectionNotifications: Bool = false | ||
|
||
public override init(frame frameRect: NSRect, textContainer container: NSTextContainer?) { | ||
super.init(frame: frameRect, textContainer: container) | ||
|
||
self.textContainerInset = CGSize(width: 5.0, height: 5.0) | ||
} | ||
|
||
public convenience init() { | ||
self.init(frame: .zero, textContainer: nil) | ||
} | ||
|
||
@available(*, unavailable) | ||
public required init?(coder: NSCoder) { | ||
fatalError("init(coder:) has not been implemented") | ||
} | ||
} | ||
|
||
@available(macOS 12.0, *) | ||
extension BaseTextView { | ||
open override var textContainerInset: NSSize { | ||
get { super.textContainerInset } | ||
set { | ||
let effectiveInset = NSSize(width: max(newValue.width, 5.0), height: newValue.height) | ||
|
||
if effectiveInset != newValue { | ||
logger.warning("textContainerInset has been modified to workaround scrolling bug") | ||
} | ||
|
||
super.textContainerInset = effectiveInset | ||
} | ||
} | ||
|
||
open override func scrollRangeToVisible(_ range: NSRange) { | ||
// this scroll won't actually happen if the desired location is too far to the trailing edge. This is because at this point the view's bounds haven't yet been resized, so the scroll cannot happen. FB13100459 | ||
|
||
self.activeScrollValue = (range, bounds.size) | ||
|
||
super.scrollRangeToVisible(range) | ||
} | ||
|
||
open override func setFrameSize(_ newSize: NSSize) { | ||
super.setFrameSize(newSize) | ||
|
||
guard | ||
let (range, size) = self.activeScrollValue, | ||
newSize != size | ||
else { | ||
return | ||
} | ||
|
||
// this produces scroll/text flicker. But I'm unable to find a better solution. | ||
self.activeScrollValue = nil | ||
|
||
DispatchQueue.main.async { | ||
super.scrollRangeToVisible(range) | ||
} | ||
} | ||
} | ||
|
||
@available(macOS 12.0, *) | ||
extension BaseTextView { | ||
open override func paste(_ sender: Any?) { | ||
let handled = delegate?.textView?(self, doCommandBy: #selector(paste(_:))) ?? false | ||
|
||
if handled == false { | ||
super.paste(sender) | ||
} | ||
} | ||
|
||
open override func pasteAsRichText(_ sender: Any?) { | ||
let handled = delegate?.textView?(self, doCommandBy: #selector(pasteAsRichText(_:))) ?? false | ||
|
||
if handled == false { | ||
super.pasteAsRichText(sender) | ||
} | ||
} | ||
|
||
open override func pasteAsPlainText(_ sender: Any?) { | ||
let handled = delegate?.textView?(self, doCommandBy: #selector(pasteAsPlainText(_:))) ?? false | ||
|
||
if handled == false { | ||
super.pasteAsPlainText(sender) | ||
} | ||
} | ||
} | ||
|
||
@available(macOS 12.0, *) | ||
extension BaseTextView { | ||
open override func keyDown(with event: NSEvent) { | ||
onKeyDown(event) { | ||
super.keyDown(with: event) | ||
} | ||
} | ||
|
||
open override func flagsChanged(with event: NSEvent) { | ||
onFlagsChanged(event) { | ||
super.flagsChanged(with: event) | ||
} | ||
} | ||
|
||
open override func mouseDown(with event: NSEvent) { | ||
onMouseDown(event) { | ||
super.mouseDown(with: event) | ||
} | ||
} | ||
} | ||
|
||
@available(macOS 12.0, *) | ||
extension BaseTextView { | ||
open override func setSelectedRanges(_ ranges: [NSValue], affinity: NSSelectionAffinity, stillSelecting stillSelectingFlag: Bool) { | ||
let oldRanges = selectedRanges | ||
|
||
super.setSelectedRanges(ranges, affinity: affinity, stillSelecting: stillSelectingFlag) | ||
|
||
// try to filter out notifications that have already been set | ||
if ranges == lastSelectionValue { | ||
return | ||
} | ||
|
||
lastSelectionValue = ranges | ||
|
||
if stillSelectingFlag && continuousSelectionNotifications { | ||
let userInfo: [AnyHashable: Any] = [NSOldSelectedCharacterRanges: oldRanges] | ||
|
||
NotificationCenter.default.post(name: NSTextView.didChangeSelectionNotification, object: self, userInfo: userInfo) | ||
} | ||
} | ||
} | ||
#endif |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
import XCTest | ||
import BaseTextView | ||
|
||
final class MockTextViewDelegate: NSObject, NSTextViewDelegate { | ||
var textViewDoCommandBy: (_ commandSelector: Selector) -> Bool = { _ in true } | ||
|
||
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { | ||
textViewDoCommandBy(commandSelector) | ||
} | ||
} | ||
|
||
@MainActor | ||
final class BaseTextViewTests: XCTestCase { | ||
func testStockMissingDoCommandBy() { | ||
let view = NSTextView() | ||
let delegate = MockTextViewDelegate() | ||
|
||
view.delegate = delegate | ||
|
||
var invoked = false | ||
|
||
delegate.textViewDoCommandBy = { _ in | ||
invoked = true | ||
|
||
return true | ||
} | ||
|
||
view.paste(nil) | ||
view.pasteAsRichText(nil) | ||
view.pasteAsPlainText(nil) | ||
XCTAssertFalse(invoked, "Should this ever fail, behavior has changed!") | ||
} | ||
|
||
func testPasteDoCommandBy() { | ||
let view = BaseTextView() | ||
let delegate = MockTextViewDelegate() | ||
|
||
view.delegate = delegate | ||
|
||
var invoked = false | ||
|
||
delegate.textViewDoCommandBy = { | ||
XCTAssertEqual($0, #selector(NSTextView().paste(_:))) | ||
|
||
invoked = true | ||
|
||
return true | ||
} | ||
|
||
view.paste(nil) | ||
XCTAssertTrue(invoked) | ||
} | ||
|
||
func testPasteAsRichTextDoCommandBy() { | ||
let view = BaseTextView() | ||
let delegate = MockTextViewDelegate() | ||
|
||
view.delegate = delegate | ||
|
||
var invoked = false | ||
|
||
delegate.textViewDoCommandBy = { | ||
XCTAssertEqual($0, #selector(NSTextView().pasteAsRichText(_:))) | ||
|
||
invoked = true | ||
|
||
return true | ||
} | ||
|
||
view.pasteAsRichText(nil) | ||
XCTAssertTrue(invoked) | ||
} | ||
|
||
func testPasteAsPlainTextDoCommandBy() { | ||
let view = BaseTextView() | ||
let delegate = MockTextViewDelegate() | ||
|
||
view.delegate = delegate | ||
|
||
var invoked = false | ||
|
||
delegate.textViewDoCommandBy = { | ||
XCTAssertEqual($0, #selector(NSTextView().pasteAsPlainText(_:))) | ||
|
||
invoked = true | ||
|
||
return true | ||
} | ||
|
||
view.pasteAsPlainText(nil) | ||
XCTAssertTrue(invoked) | ||
} | ||
} | ||
|
Oops, something went wrong.