Skip to content

Commit

Permalink
Cross-platform example
Browse files Browse the repository at this point in the history
  • Loading branch information
mattmassicotte committed Apr 4, 2024
1 parent 42210a0 commit 5c752f3
Show file tree
Hide file tree
Showing 8 changed files with 129 additions and 74 deletions.
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ let package = Package(
.library(name: "Neon", targets: ["Neon"]),
],
dependencies: [
.package(url: "https://github.com/ChimeHQ/SwiftTreeSitter", revision: "1ee3af86c3973c900a30e15507d117c58ed5acb2"),
.package(url: "https://github.com/ChimeHQ/SwiftTreeSitter", revision: "b01904a3737649c1d8520106bbb285724fe5b0bb"),
.package(url: "https://github.com/ChimeHQ/Rearrange", from: "1.8.1"),
],
targets: [
Expand Down
4 changes: 2 additions & 2 deletions Projects/NeonExample.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -330,8 +330,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/mattmassicotte/nsui.git";
requirement = {
branch = main;
kind = branch;
kind = revision;
revision = d000e76f5587a1b7843717872b2f4a2318b0efbf;
};
};
/* End XCRemoteSwiftPackageReference section */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/mattmassicotte/nsui.git",
"state" : {
"branch" : "main",
"revision" : "783dce536c266956b3471ae77fb3b8a755184858"
"revision" : "d000e76f5587a1b7843717872b2f4a2318b0efbf"
}
},
{
Expand All @@ -23,7 +22,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/ChimeHQ/SwiftTreeSitter",
"state" : {
"revision" : "1ee3af86c3973c900a30e15507d117c58ed5acb2"
"revision" : "b01904a3737649c1d8520106bbb285724fe5b0bb"
}
},
{
Expand Down
28 changes: 10 additions & 18 deletions Projects/NeonExample/TextViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,14 @@ final class TextViewController: NSUIViewController {
self.textView = NSUITextView()
}

// enable non-continguous layout for TextKit 1
if #available(macOS 12.0, iOS 16.0, *), textView.textLayoutManager == nil {
#if canImport(AppKit) && !targetEnvironment(macCatalyst)
textView.layoutManager?.allowsNonContiguousLayout = true
#else
textView.layoutManager.allowsNonContiguousLayout = true
#endif
}

self.highlighter = try! Self.makeHighlighter(for: textView)

super.init(nibName: nil, bundle: nil)

// enable non-continguous layout for TextKit 1
if #available(macOS 12.0, iOS 16.0, *), textView.textLayoutManager == nil {
textView.nsuiLayoutManager?.allowsNonContiguousLayout = true
}
}

@available(*, unavailable)
Expand All @@ -44,13 +40,9 @@ final class TextViewController: NSUIViewController {
private static func makeHighlighter(for textView: NSUITextView) throws -> TextViewHighlighter {
let regularFont = NSUIFont.monospacedSystemFont(ofSize: 16, weight: .regular)
let boldFont = NSUIFont.monospacedSystemFont(ofSize: 16, weight: .bold)
let italicDescriptor = regularFont.fontDescriptor.withSymbolicTraits(.traitItalic)
let italicDescriptor = regularFont.fontDescriptor.nsuiWithSymbolicTraits(.traitItalic) ?? regularFont.fontDescriptor

#if canImport(AppKit) && !targetEnvironment(macCatalyst)
let italicFont = NSUIFont(descriptor: italicDescriptor, size: 16) ?? regularFont
#elseif canImport(UIKit)
let italicFont = NSUIFont(descriptor: italicDescriptor ?? regularFont.fontDescriptor, size: 16)
#endif
let italicFont = NSUIFont(nsuiDescriptor: italicDescriptor, size: 16) ?? regularFont

// Set the default styles. This is applied by stock `NSTextStorage`s during
// so-called "attribute fixing" when you type, and we emulate that as
Expand Down Expand Up @@ -124,13 +116,13 @@ final class TextViewController: NSUIViewController {
textView.isRichText = false // Discards any attributes when pasting.

self.view = scrollView
#else
self.view = textView
#endif

// this has to be done after the textview has been embedded in the scrollView if
// it wasn't that way on creation
highlighter.observeEnclosingScrollView()
#else
self.view = textView
#endif

regularTest()
}
Expand Down
2 changes: 0 additions & 2 deletions Sources/Neon/PlatformTextSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,10 @@ import RangeState
#if os(macOS) && !targetEnvironment(macCatalyst)
import AppKit

public typealias TextStorageEditActions = NSTextStorageEditActions
public typealias TextView = NSTextView
#elseif os(iOS) || os(visionOS)
import UIKit

public typealias TextStorageEditActions = NSTextStorage.EditActions
public typealias TextView = UITextView
#endif

Expand Down
53 changes: 53 additions & 0 deletions Sources/Neon/TextStorageDelegateBuffer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import Foundation

#if os(macOS) && !targetEnvironment(macCatalyst)
import AppKit

typealias TextStorageEditActions = NSTextStorageEditActions
#elseif os(iOS) || os(visionOS)
import UIKit

typealias TextStorageEditActions = NSTextStorage.EditActions
#endif

import RangeState

#if os(macOS) || os(iOS) || os(visionOS)
final class TextStorageDelegate: NSObject {
typealias ChangeHandler = (NSRange, Int) -> Void

public var storage: NSTextStorage? {
didSet {
oldValue?.delegate = nil
storage?.delegate = self
}
}
public var willChangeContent: ChangeHandler = { _, _ in }
public var didChangeContent: ChangeHandler = { _, _ in }
}

extension TextStorageDelegate: NSTextStorageDelegate {
public func textStorage(
_ textStorage: NSTextStorage,
willProcessEditing editedMask: TextStorageEditActions,
range editedRange: NSRange,
changeInLength delta: Int
) {
guard editedMask.contains(.editedCharacters) else { return }

willChangeContent(editedRange, delta)
}

public func textStorage(
_ textStorage: NSTextStorage,
didProcessEditing editedMask: TextStorageEditActions,
range editedRange: NSRange,
changeInLength delta: Int
) {
// it's important to filter these, as attribute changes can be caused by styling. This will result in an infinite loop.
guard editedMask.contains(.editedCharacters) else { return }

didChangeContent(editedRange, delta)
}
}
#endif
95 changes: 47 additions & 48 deletions Sources/Neon/TextViewHighlighter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ extension TextView {
/// for a TextView. The created instance will become the delegate of the
/// view's `NSTextStorage`.
@MainActor
public final class TextViewHighlighter: NSObject {
public final class TextViewHighlighter {
private typealias Styler = TextSystemStyler<TextViewSystemInterface>

public struct Configuration {
Expand Down Expand Up @@ -67,9 +67,11 @@ public final class TextViewHighlighter: NSObject {
private let interface: TextViewSystemInterface
private let client: TreeSitterClient
private let buffer = RangeInvalidationBuffer()
private let storageDelegate = TextStorageDelegate()

#if os(iOS) || os(visionOS)
private var frameObservation: NSKeyValueObservation?
private var lastVisibleRange = NSRange.zero
#endif

public init(
Expand Down Expand Up @@ -100,20 +102,51 @@ public final class TextViewHighlighter: NSObject {
tokenProvider: tokenProvider
)

super.init()

buffer.invalidationHandler = { [styler] in
styler.invalidate($0)

styler.validate()
}

try textView.getTextStorage().delegate = self
storageDelegate.willChangeContent = { [buffer, client] range, _ in
// a change happening, start buffering invalidations
buffer.beginBuffering()

client.willChangeContent(in: range)
}

storageDelegate.didChangeContent = { [buffer, client, styler] range, delta in
let adjustedRange = NSRange(location: range.location, length: range.length - delta)

client.didChangeContent(in: adjustedRange, delta: delta)
styler.didChangeContent(in: adjustedRange, delta: delta)

// At this point in mutation processing, it is unsafe to apply style changes. Ideally, we'd have a hook so we can know when it is ok. But, no such system exists for stock TextKit 1/2. So, instead we just let the runloop turn. This is *probably* safe, if the text does not change again, but can also result in flicker.
DispatchQueue.main.async {
buffer.endBuffering()
}

}

try textView.getTextStorage().delegate = storageDelegate

observeEnclosingScrollView()

invalidate(.all)
}

/// Perform manual invalidation on the underlying highlighter
public func invalidate(_ target: RangeTarget) {
buffer.invalidate(target)
}

/// Inform the client that calls to languageConfiguration may change.
public func languageConfigurationChanged(for name: String) {
client.languageConfigurationChanged(for: name)
}
}

extension TextViewHighlighter {
public func observeEnclosingScrollView() {
#if os(macOS) && !targetEnvironment(macCatalyst)
guard let scrollView = textView.enclosingScrollView else {
Expand All @@ -135,9 +168,17 @@ public final class TextViewHighlighter: NSObject {
object: scrollView.contentView
)
#elseif os(iOS) || os(visionOS)
self.frameObservation = textView.observe(\.contentOffset) { [styler] view, _ in
self.frameObservation = textView.observe(\.contentOffset) { [weak self] view, _ in
MainActor.backport.assumeIsolated {
styler.visibleContentDidChange()
guard let self = self else { return }

self.lastVisibleRange = self.textView.visibleTextRange

DispatchQueue.main.async {
guard self.textView.visibleTextRange == self.lastVisibleRange else { return }

self.styler.visibleContentDidChange()
}
}
}
#endif
Expand All @@ -146,48 +187,6 @@ public final class TextViewHighlighter: NSObject {
@objc private func visibleContentChanged(_ notification: NSNotification) {
styler.visibleContentDidChange()
}

/// Perform manual invalidation on the underlying highlighter
public func invalidate(_ target: RangeTarget) {
buffer.invalidate(target)
}

public func languageConfigurationChanged(for name: String) {
client.languageConfigurationChanged(for: name)
}
}

extension TextViewHighlighter: NSTextStorageDelegate {
public nonisolated func textStorage(_ textStorage: NSTextStorage, willProcessEditing editedMask: TextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int) {
MainActor.backport.assumeIsolated {
guard editedMask.contains(.editedCharacters) else { return }

// a change happening, start buffering invalidations
buffer.beginBuffering()

client.willChangeContent(in: editedRange)
}
}

public nonisolated func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: TextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int) {
MainActor.backport.assumeIsolated {
// Avoid potential infinite loop in synchronous highlighting. If attributes
// are stored in `textStorage`, that applies `.editedAttributes` only.
// We don't need to re-apply highlighting in that case.
// (With asynchronous highlighting, it's not blocking, but also never stops.)
guard editedMask.contains(.editedCharacters) else { return }

let adjustedRange = NSRange(location: editedRange.location, length: editedRange.length - delta)

client.didChangeContent(in: adjustedRange, delta: delta)
styler.didChangeContent(in: adjustedRange, delta: delta)

// At this point in mutation processing, it is unsafe to apply style changes. Ideally, we'd have a hook so we can know when it is ok. But, no such system exists for stock TextKit 1/2. So, instead we just let the runloop turn. This is *probably* safe, if the text does not change again, but can also result in flicker.
DispatchQueue.main.async {
self.buffer.endBuffering()
}
}
}
}

#endif
14 changes: 14 additions & 0 deletions Sources/RangeState/RangeTarget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,17 @@ extension RangeTarget {
}
}
}

extension RangeTarget: CustomDebugStringConvertible {
public var debugDescription: String {
switch self {
case .all:
"all"
case let .range(range):
range.debugDescription
case let .set(set):
set.nsRangeView.debugDescription
}
}
}

0 comments on commit 5c752f3

Please sign in to comment.