From e003916018c63f9e4d8b59882b750545b77e5e42 Mon Sep 17 00:00:00 2001 From: Jiaan Fang Date: Wed, 31 Jan 2024 00:52:41 +0800 Subject: [PATCH] initial commit --- .gitignore | 90 ++++++++++++++ LICENSE | 21 ++++ Package.swift | 29 +++++ README.md | 51 ++++++++ .../Coordinator.swift | 104 ++++++++++++++++ .../NeonCodeEditLanguagesPlugin.swift | 40 +++++++ .../STTextViewSystemInterface.swift | 53 +++++++++ .../Theme/NSColor+hex.swift | 72 +++++++++++ .../Theme/Theme.swift | 112 ++++++++++++++++++ .../Theme/TokenName.swift | 20 ++++ 10 files changed, 592 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/NeonCodeEditLanguagesPlugin/Coordinator.swift create mode 100644 Sources/NeonCodeEditLanguagesPlugin/NeonCodeEditLanguagesPlugin.swift create mode 100644 Sources/NeonCodeEditLanguagesPlugin/STTextViewSystemInterface.swift create mode 100644 Sources/NeonCodeEditLanguagesPlugin/Theme/NSColor+hex.swift create mode 100644 Sources/NeonCodeEditLanguagesPlugin/Theme/Theme.swift create mode 100644 Sources/NeonCodeEditLanguagesPlugin/Theme/TokenName.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bcc694 --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +Package.resolved +# *.xcodeproj + +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +.swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +*.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cdf7a46 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Jiaan Fang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..825996d --- /dev/null +++ b/Package.swift @@ -0,0 +1,29 @@ +// swift-tools-version: 5.8 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "NeonCodeEditLanguagesPlugin", + platforms: [.macOS(.v13)], + products: [ + .library( + name: "NeonCodeEditLanguagesPlugin", + targets: ["NeonCodeEditLanguagesPlugin"]), + ], + dependencies: [ + .package(url: "https://github.com/krzyzanowskim/STTextView", from: "0.8.13"), + .package(url: "https://github.com/ChimeHQ/Neon.git", from: "0.6.0"), + .package(url: "https://github.com/CodeEditApp/CodeEditLanguages", from: "0.1.18") + ], + targets: [ + .target( + name: "NeonCodeEditLanguagesPlugin", + dependencies: [ + .product(name: "STTextView", package: "STTextView"), + "Neon", + .product(name: "CodeEditLanguages", package: "CodeEditLanguages") + ] + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..1d49dac --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +[STTextView](https://github.com/krzyzanowskim/STTextView) Source Code Syntax Highlighting with [TreeSitter](https://tree-sitter.github.io/tree-sitter/), [Neon](https://github.com/ChimeHQ/Neon) and [CodeEditLanguages](https://github.com/CodeEditApp/CodeEditLanguages). + +Most code in this repo is borrowed from [STTextView-Plugin-Neon](https://github.com/krzyzanowskim/STTextView-Plugin-Neon), with some adaptations to work with CodeEditLanguages. + + +## Installation + +Add the plugin package as a dependency of your application, then register/add it to the STTextView instance: + +```swift +import NeonCodeEditLanguagesPlugin + +textView.addPlugin( + NeonCodeEditLanguagesPlugin( + theme: .default, + language: .go + ) +) +``` + +SwiftUI: +```swift +import SwiftUI +import STTextViewUI +import NeonCodeEditLanguagesPlugin + +struct ContentView: View { + @State private var text: AttributedString = "" + @State private var selection: NSRange? + var body: some View { + STTextViewUI.TextView( + text: $text, + selection: $selection, + options: [.wrapLines, .highlightSelectedLine], + plugins: [NeonCodeEditLanguagesPlugin(theme: .default, language: .go)] + ) + .textViewFont(.monospacedDigitSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)) + .onAppear { + loadContent() + } + } + + private func loadContent() { + // (....) + self.text = AttributedString(string) + } +} +``` + +Default Theme + diff --git a/Sources/NeonCodeEditLanguagesPlugin/Coordinator.swift b/Sources/NeonCodeEditLanguagesPlugin/Coordinator.swift new file mode 100644 index 0000000..eb31f1e --- /dev/null +++ b/Sources/NeonCodeEditLanguagesPlugin/Coordinator.swift @@ -0,0 +1,104 @@ +import Cocoa +import STTextView + +import Neon +import TreeSitterClient +import SwiftTreeSitter + +import CodeEditLanguages + +public class Coordinator { + private(set) var highlighter: Neon.Highlighter? + private let language: CodeLanguage + private let tsLanguage: SwiftTreeSitter.Language + private let tsClient: TreeSitterClient + private var prevViewportRange: NSTextRange? + + init(textView: STTextView, theme: Theme, language: CodeLanguage) { + tsLanguage = language.language! + self.language = language + + tsClient = try! TreeSitterClient(language: tsLanguage) { codePointIndex in + guard let location = textView.textContentManager.location(at: codePointIndex), + let position = textView.textContentManager.position(location) + else { + return .zero + } + + return Point(row: position.row, column: position.column) + } + + + tsClient.invalidationHandler = { [weak self] indexSet in + DispatchQueue.main.async { + self?.highlighter?.invalidate(.set(indexSet)) + } + } + + // set textview default font to theme default font + textView.font = theme.tokens[.default]?.font?.value ?? textView.font + + DispatchQueue.main.async { + self.highlighter = Neon.Highlighter(textInterface: STTextViewSystemInterface(textView: textView) { neonToken in + var attributes: [NSAttributedString.Key: Any] = [:] + if let tvFont = textView.font { + attributes[.font] = tvFont + } + + if let themeValue = theme.tokens[TokenName(neonToken.name)] { + attributes[.foregroundColor] = themeValue.color.value + + if let font = themeValue.font?.value { + attributes[.font] = font + } + } else if let themeValue = theme.tokens[.default]{ + attributes[.foregroundColor] = themeValue.color.value + + if let font = themeValue.font?.value { + attributes[.font] = font + } + } + + return !attributes.isEmpty ? attributes : nil + }, tokenProvider: self.tokenProvider(textContentManager: textView.textContentManager)) + } + + // initial parse of the whole content + tsClient.willChangeContent(in: NSRange(textView.textContentManager.documentRange, in: textView.textContentManager)) + tsClient.didChangeContent(in: NSRange(textView.textContentManager.documentRange, in: textView.textContentManager), delta: textView.textContentManager.length, limit: textView.textContentManager.length, readHandler: Parser.readFunction(for: textView.textContentManager.attributedString(in: nil)?.string ?? ""), completionHandler: {}) + } + + private func tokenProvider(textContentManager: NSTextContentManager) -> Neon.TokenProvider? { + guard let highlightsQuery = try? tsLanguage.query(contentsOf: language.queryURL!) else { + return nil + } + + return tsClient.tokenProvider(with: highlightsQuery) { range, _ in + guard range.isEmpty == false else { return nil } + return textContentManager.attributedString(in: NSTextRange(range, provider: textContentManager))?.string + } + } + + func updateViewportRange(_ range: NSTextRange?) { + if range != prevViewportRange { + DispatchQueue.main.async { + self.highlighter?.visibleContentDidChange() + } + } + prevViewportRange = range + } + + func willChangeContent(in range: NSRange) { + tsClient.willChangeContent(in: range) + } + + func didChangeContent(_ textContentManager: NSTextContentManager, in range: NSRange, delta: Int, limit: Int) { + /// TODO: Instead get the *whole* string over and over (can be expensive for large documents) + /// implement maybe a reader function that read what needed only (is it possible?) + if let str = textContentManager.attributedString(in: nil)?.string { + let readFunction = Parser.readFunction(for: str) + tsClient.didChangeContent(in: range, delta: delta, limit: limit, readHandler: readFunction, completionHandler: {}) + } + + } +} diff --git a/Sources/NeonCodeEditLanguagesPlugin/NeonCodeEditLanguagesPlugin.swift b/Sources/NeonCodeEditLanguagesPlugin/NeonCodeEditLanguagesPlugin.swift new file mode 100644 index 0000000..615ee33 --- /dev/null +++ b/Sources/NeonCodeEditLanguagesPlugin/NeonCodeEditLanguagesPlugin.swift @@ -0,0 +1,40 @@ +import Cocoa + +import STTextView + +import CodeEditLanguages + +public struct NeonCodeEditLanguagesPlugin: STPlugin { + private let theme: Theme + private let language: CodeLanguage + + public init(theme: Theme = .default, language: CodeLanguage) { + self.theme = theme + self.language = language + } + + public func setUp(context: any Context) { + + context.events.onWillChangeText { affectedRange in + let range = NSRange(affectedRange, in: context.textView.textContentManager) + context.coordinator.willChangeContent(in: range) + } + + context.events.onDidChangeText { affectedRange, replacementString in + guard let replacementString else { return } + + let range = NSRange(affectedRange, in: context.textView.textContentManager) + context.coordinator.didChangeContent(context.textView.textContentManager, in: range, delta: replacementString.utf16.count - range.length, limit: context.textView.textContentManager.length) + } + + context.events.onDidLayoutViewport { viewportRange in + context.coordinator.updateViewportRange(viewportRange) + } + } + + public func makeCoordinator(context: CoordinatorContext) -> Coordinator { + Coordinator(textView: context.textView, theme: theme, language: language) + } + +} + diff --git a/Sources/NeonCodeEditLanguagesPlugin/STTextViewSystemInterface.swift b/Sources/NeonCodeEditLanguagesPlugin/STTextViewSystemInterface.swift new file mode 100644 index 0000000..06911a5 --- /dev/null +++ b/Sources/NeonCodeEditLanguagesPlugin/STTextViewSystemInterface.swift @@ -0,0 +1,53 @@ +import Cocoa +import STTextView +import Neon + +class STTextViewSystemInterface: TextSystemInterface { + + typealias AttributeProvider = (Neon.Token) -> [NSAttributedString.Key: Any]? + + private let textView: STTextView + private let attributeProvider: AttributeProvider + + init(textView: STTextView, attributeProvider: @escaping AttributeProvider) { + self.textView = textView + self.attributeProvider = attributeProvider + } + + func clearStyle(in range: NSRange) { + guard let textRange = NSTextRange(range, in: textView.textContentManager) else { + assertionFailure() + return + } + + textView.textLayoutManager.removeRenderingAttribute(.foregroundColor, for: textRange) + if let defaultFont = textView.font { + textView.addAttributes([.font: defaultFont], range: range) + } + } + + func applyStyle(to token: Neon.Token) { + print(token) + guard let attrs = attributeProvider(token), + let textRange = NSTextRange(token.range, in: textView.textContentManager) + else { + return + } + + for attr in attrs { + textView.textLayoutManager.addRenderingAttribute(attr.key, value: attr.value, for: textRange) + } + } + + var length: Int { + textView.textContentManager.length + } + + var visibleRange: NSRange { + guard let viewportRange = textView.textLayoutManager.textViewportLayoutController.viewportRange else { + return .zero + } + + return NSRange(viewportRange, provider: textView.textContentManager) + } +} diff --git a/Sources/NeonCodeEditLanguagesPlugin/Theme/NSColor+hex.swift b/Sources/NeonCodeEditLanguagesPlugin/Theme/NSColor+hex.swift new file mode 100644 index 0000000..85c9d51 --- /dev/null +++ b/Sources/NeonCodeEditLanguagesPlugin/Theme/NSColor+hex.swift @@ -0,0 +1,72 @@ + +import Cocoa + +// https://github.com/thii/SwiftHEXColors + +extension NSColor { + + private convenience init?(hex3: Int64, alpha: Float) { + self.init(red: CGFloat( ((hex3 & 0xF00) >> 8).duplicate4bits() ) / 255.0, + green: CGFloat( ((hex3 & 0x0F0) >> 4).duplicate4bits() ) / 255.0, + blue: CGFloat( ((hex3 & 0x00F) >> 0).duplicate4bits() ) / 255.0, + alpha: CGFloat(alpha)) + } + + private convenience init?(hex4: Int64, alpha: Float?) { + self.init(red: CGFloat( ((hex4 & 0xF000) >> 12).duplicate4bits() ) / 255.0, + green: CGFloat( ((hex4 & 0x0F00) >> 8).duplicate4bits() ) / 255.0, + blue: CGFloat( ((hex4 & 0x00F0) >> 4).duplicate4bits() ) / 255.0, + alpha: alpha.map(CGFloat.init(_:)) ?? CGFloat( ((hex4 & 0x000F) >> 0).duplicate4bits() ) / 255.0) + } + + private convenience init?(hex6: Int64, alpha: Float) { + self.init(red: CGFloat( (hex6 & 0xFF0000) >> 16 ) / 255.0, + green: CGFloat( (hex6 & 0x00FF00) >> 8 ) / 255.0, + blue: CGFloat( (hex6 & 0x0000FF) >> 0 ) / 255.0, alpha: CGFloat(alpha)) + } + + private convenience init?(hex8: Int64, alpha: Float?) { + self.init(red: CGFloat( (hex8 & 0xFF000000) >> 24 ) / 255.0, + green: CGFloat( (hex8 & 0x00FF0000) >> 16 ) / 255.0, + blue: CGFloat( (hex8 & 0x0000FF00) >> 8 ) / 255.0, + alpha: alpha.map(CGFloat.init(_:)) ?? CGFloat( (hex8 & 0x000000FF) >> 0 ) / 255.0) + } + + convenience init?(hexString: String, alpha: Float? = nil) { + var hex = hexString + + // Check for hash and remove the hash + if hex.hasPrefix("#") { + hex = String(hex[hex.index(after: hex.startIndex)...]) + } + + guard let hexVal = Int64(hex, radix: 16) else { + self.init() + return nil + } + + switch hex.count { + case 3: + self.init(hex3: hexVal, alpha: alpha ?? 1.0) + case 4: + self.init(hex4: hexVal, alpha: alpha) + case 6: + self.init(hex6: hexVal, alpha: alpha ?? 1.0) + case 8: + self.init(hex8: hexVal, alpha: alpha) + default: + // Note: + // The swift 1.1 compiler is currently unable to destroy partially initialized classes in all cases, + // so it disallows formation of a situation where it would have to. We consider this a bug to be fixed + // in future releases, not a feature. -- Apple Forum + self.init() + return nil + } + } +} + +private extension Int64 { + func duplicate4bits() -> Int64 { + return (self << 4) + self + } +} diff --git a/Sources/NeonCodeEditLanguagesPlugin/Theme/Theme.swift b/Sources/NeonCodeEditLanguagesPlugin/Theme/Theme.swift new file mode 100644 index 0000000..42ac807 --- /dev/null +++ b/Sources/NeonCodeEditLanguagesPlugin/Theme/Theme.swift @@ -0,0 +1,112 @@ +import Cocoa + +public struct Theme: Decodable { + + public struct Value: Decodable { + let color: Theme.Color + let font: Theme.Font? + + public init(color: Theme.Color, font: Theme.Font? = nil) { + self.color = color + self.font = font + } + } + + let tokens: [TokenName: Theme.Value] + + public init(_ tokens: [TokenName : Theme.Value]) { + self.tokens = tokens + } +} + +extension Theme { + + public struct Font: Decodable { + public let value: NSFont + + enum CodingKeys: CodingKey { + case fontName + case size + } + + public init(_ font: NSFont) { + self.value = font + } + + public func bold() -> Self { + Theme.Font(NSFont(descriptor: value.fontDescriptor.withSymbolicTraits(.bold), size: value.pointSize)!) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let fontName = try container.decode(String.self, forKey: .fontName) + let size = try container.decode(CGFloat.self, forKey: .size) + value = NSFont(name: fontName, size: size)! + } + } + + public struct Color: Decodable { + public let value: NSColor + + public init(_ color: NSColor) { + value = color + } + + public init(_ color: CGColor) { + value = NSColor(cgColor: color)! + } + + enum CodingKeys: CodingKey { + case light + case dark + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let lightColor = try NSColor(hexString: container.decode(String.self, forKey: .light))! + let darkColor = try NSColor(hexString: container.decode(String.self, forKey: .dark))! + + self.value = NSColor(name: nil) { appearance in + switch appearance.name { + case .aqua, .vibrantLight, .accessibilityHighContrastAqua, .accessibilityHighContrastVibrantLight: + return lightColor + case .darkAqua, .vibrantDark, .accessibilityHighContrastDarkAqua, .accessibilityHighContrastVibrantDark: + return darkColor + default: + return lightColor + } + } + } + } +} + +extension Theme { + public static let `default` = Theme( + [ + "string": Theme.Value(color: Color(NSColor(red: 153 / 255, green: 0, blue: 0, alpha: 1)), font: nil), + "number": Theme.Value(color: Color(NSColor(red: 28 / 255, green: 0 / 255, blue: 207 / 255, alpha: 1)), font: nil), + + "keyword": Theme.Value(color: Color(NSColor(red: 155 / 255, green: 35 / 255, blue: 147 / 255, alpha: 1)), font: Font(NSFont.monospacedSystemFont(ofSize: 0, weight: .bold))), + "include": Theme.Value(color: Color(NSColor(red: 155 / 255, green: 35 / 255, blue: 147 / 255, alpha: 1)), font: nil), + "constructor": Theme.Value(color: Color(NSColor(red: 155 / 255, green: 35 / 255, blue: 147 / 255, alpha: 1)), font: Font(NSFont.monospacedSystemFont(ofSize: 0, weight: .bold))), + "keyword.function": Theme.Value(color: Color(NSColor(red: 50 / 255, green: 109 / 255, blue: 116 / 255, alpha: 1)), font: nil), + "keyword.return": Theme.Value(color: Color(NSColor(red: 155 / 255, green: 35 / 255, blue: 147 / 255, alpha: 1)), font: nil), + "variable.builtin": Theme.Value(color: Color(NSColor(red: 50 / 255, green: 109 / 255, blue: 116 / 255, alpha: 1)), font: nil), + "boolean": Theme.Value(color: Color(NSColor(red: 155 / 255, green: 35 / 255, blue: 147 / 255, alpha: 1)), font: nil), + + "type": Theme.Value(color: Color(NSColor(red: 11 / 255, green: 79 / 255, blue: 121 / 255, alpha: 1)), font: nil), + + "function.call": Theme.Value(color: Color(NSColor(red: 11 / 255, green: 79 / 255, blue: 121 / 255, alpha: 1)), font: nil), + + "variable": Theme.Value(color: Color(NSColor.textColor), font: nil), + "property": Theme.Value(color: Color(NSColor(red: 50 / 255, green: 109 / 255, blue: 116 / 255, alpha: 1)), font: nil), + "method": Theme.Value(color: Color(NSColor(red: 50 / 255, green: 109 / 255, blue: 116 / 255, alpha: 1)), font: nil), + "parameter": Theme.Value(color: Color(NSColor.textColor), font: nil), + "comment": Theme.Value(color: Color(NSColor.secondaryLabelColor), font: nil), + "operator": Theme.Value(color: Color(NSColor.textColor), font: nil), + + .default: Theme.Value(color: Color(NSColor.textColor), font: Font(NSFont.monospacedSystemFont(ofSize: 0, weight: .regular))) + ] + ) +} diff --git a/Sources/NeonCodeEditLanguagesPlugin/Theme/TokenName.swift b/Sources/NeonCodeEditLanguagesPlugin/Theme/TokenName.swift new file mode 100644 index 0000000..8fab5f1 --- /dev/null +++ b/Sources/NeonCodeEditLanguagesPlugin/Theme/TokenName.swift @@ -0,0 +1,20 @@ +import Foundation + +public struct TokenName: Hashable, Decodable, CustomStringConvertible, ExpressibleByStringLiteral { + + public static let `default`: TokenName = "EB6F2FBA-B90E-41BC-874E-67916516D889" + + private let value: String + + public init(stringLiteral value: StringLiteralType) { + self.value = value + } + + public init(_ string: StringLiteralType) { + self.value = string + } + + public var description: String { + value + } +}