Skip to content

Commit

Permalink
Importing BaseTextView
Browse files Browse the repository at this point in the history
  • Loading branch information
mattmassicotte committed Sep 3, 2023
1 parent e0aaed2 commit f4e5859
Show file tree
Hide file tree
Showing 16 changed files with 1,516 additions and 7 deletions.
4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@ let package = Package(
platforms: [.macOS(.v10_13), .iOS(.v15), .tvOS(.v15)],
products: [
.library(name: "TextViewPlus", targets: ["TextViewPlus"]),
.library(name: "BaseTextView", targets: ["BaseTextView"]),
],
dependencies: [
.package(url: "https://github.com/ChimeHQ/Rearrange", from: "1.4.0"),
],
targets: [
.target(name: "TextViewPlus", dependencies: ["Rearrange"], swiftSettings: settings),
.testTarget(name: "TextViewPlusTests", dependencies: ["TextViewPlus"], swiftSettings: settings),

.target(name: "BaseTextView", dependencies: [], swiftSettings: settings),
.testTarget(name: "BaseTextViewTests", dependencies: ["BaseTextView"], swiftSettings: settings),
]
)
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,29 @@ This project aims to make it easier to use `NSTextView`. It was originally built
```swift
dependencies: [
.package(url: "https://github.com/ChimeHQ/TextViewPlus")
],
targets: [
.target(
name: "UseCoreFunctionality",
dependencies: [.product(name: "TextViewPlus", package: "TextViewPlus")]
),
.target(
name: "UseBaseTextView",
dependencies: [.product(name: "BaseTextView", package: "TextViewPlus")]
),
]
```

## BaseTextView

This is an `NSTextView` subclass that aims for an absolute minimal amount of changes. Things are allowed only if they are required for correct functionality. It is intended to be a drop-in replacement for `NSTextView`, and should maintain compatibilty with existing subclasses. Behaviors are appropriate for all types of text.

- Workaround for `scrollRangeToVisible` bug (FB13100459)
- Minimum `textContainerInset` enforcement to address more `scrollRangeToVisible` bugs
- Additional routing to `NSTextViewDelegate.textView(_:, doCommandBy:) -> Bool`: `paste`, `pasteAsRichText`, `pasteAsPlainText`
- Hooks for `onKeyDown`, `onFlagsChanged`, `onMouseDown`
- Configurable selection notifcation delivery via `continuousSelectionNotifications`

## NSTextView Extensions

### Ranges
Expand Down
152 changes: 152 additions & 0 deletions Sources/BaseTextView/BaseTextView.swift
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
16 changes: 9 additions & 7 deletions Sources/TextViewPlus/NSTextView+Behavior.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ extension NSTextView {
return usableWidth - rulerView.requiredThickness
}

// swiftlint:disable line_length
/// Controls the relative sizing behavior of the NSTextView and its NSTextContainer
///
/// NSTextView scrolling behavior is tricky. Correct configuration of the enclosing
/// NSScrollView is required as well. But, this method does the basic setup,
/// as well as adjusting frame positions to account for any NSScrollView rulers.
///
/// NSTextView size changes/scrolling behavior is tricky. This adjusts:
/// - `textContainer.widthTracksTextView`
/// - `textContainer?.size`: to allow unlimited height/width growth
/// - `maxSize`: to allow unlimited height/width growth
/// - `frame`: to account for `NSScrollView` rulers
///
/// Check out: https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/TextUILayer/Tasks/TextInScrollView.html
public var wrapsTextToHorizontalBounds: Bool {
get {
Expand All @@ -35,8 +36,10 @@ extension NSTextView {
textContainer?.widthTracksTextView = newValue

let max = CGFloat.greatestFiniteMagnitude
let size = NSSize(width: max, height: max)

textContainer?.size = NSSize(width: max, height: max)
textContainer?.size = size
maxSize = size

// if we are turning on wrapping, our view could be the wrong size,
// so need to adjust it. Also, the textContainer's width could have
Expand All @@ -49,5 +52,4 @@ extension NSTextView {
}
}
}
// swiftlint:enable line_length
}
94 changes: 94 additions & 0 deletions Tests/BaseTextViewTests/BaseTextViewTests.swift
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)
}
}

Loading

0 comments on commit f4e5859

Please sign in to comment.