diff --git a/.github/workflows/build-documentation.yml b/.github/workflows/build-documentation.yml index 63756f779..fdcd1c077 100644 --- a/.github/workflows/build-documentation.yml +++ b/.github/workflows/build-documentation.yml @@ -14,6 +14,7 @@ jobs: run: | cd docs git init + git config http.postBuffer 524288000 git add -A git config --local user.email "action@github.com" git config --local user.name "GitHub Action" diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/project.pbxproj b/Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/project.pbxproj index 4a583aa28..d0ba091d5 100644 --- a/Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/project.pbxproj +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/project.pbxproj @@ -8,27 +8,34 @@ /* Begin PBXBuildFile section */ 6C2265DF2D306AB7008710D7 /* CodeEditTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 6C2265DE2D306AB7008710D7 /* CodeEditTextView */; }; - 6C2265E42D306B90008710D7 /* SwiftUITextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C2265E32D306B90008710D7 /* SwiftUITextView.swift */; }; - 6C2265E62D306D37008710D7 /* TextViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C2265E52D306D37008710D7 /* TextViewController.swift */; }; - 6CCDA29B2D306A25007CD84A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6CCDA2942D306A25007CD84A /* Assets.xcassets */; }; - 6CCDA29D2D306A25007CD84A /* CodeEditTextViewExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CCDA2952D306A25007CD84A /* CodeEditTextViewExampleApp.swift */; }; - 6CCDA29E2D306A25007CD84A /* CodeEditTextViewExampleDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CCDA2962D306A25007CD84A /* CodeEditTextViewExampleDocument.swift */; }; - 6CCDA29F2D306A25007CD84A /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CCDA2972D306A25007CD84A /* ContentView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 6C2265E12D306B58008710D7 /* CodeEditTextViewExample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CodeEditTextViewExample.entitlements; sourceTree = ""; }; - 6C2265E32D306B90008710D7 /* SwiftUITextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUITextView.swift; sourceTree = ""; }; - 6C2265E52D306D37008710D7 /* TextViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextViewController.swift; sourceTree = ""; }; 6CCDA27D2D306A1B007CD84A /* CodeEditTextViewExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CodeEditTextViewExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 6CCDA2942D306A25007CD84A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 6CCDA2952D306A25007CD84A /* CodeEditTextViewExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeEditTextViewExampleApp.swift; sourceTree = ""; }; - 6CCDA2962D306A25007CD84A /* CodeEditTextViewExampleDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeEditTextViewExampleDocument.swift; sourceTree = ""; }; - 6CCDA2972D306A25007CD84A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; - 6CCDA2982D306A25007CD84A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 6CCDA2A12D306A5B007CD84A /* CodeEditTextView */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = CodeEditTextView; path = ../..; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + B6654F662DF001EB003B32B8 /* Exceptions for "CodeEditTextViewExample" folder in "CodeEditTextViewExample" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 6CCDA27C2D306A1B007CD84A /* CodeEditTextViewExample */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + B6654F5D2DF001EB003B32B8 /* CodeEditTextViewExample */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + B6654F662DF001EB003B32B8 /* Exceptions for "CodeEditTextViewExample" folder in "CodeEditTextViewExample" target */, + ); + path = CodeEditTextViewExample; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 6CCDA27A2D306A1B007CD84A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -41,29 +48,11 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 6C2265E02D306AEB008710D7 /* Documents */ = { - isa = PBXGroup; - children = ( - 6CCDA2962D306A25007CD84A /* CodeEditTextViewExampleDocument.swift */, - ); - path = Documents; - sourceTree = ""; - }; - 6C2265E22D306B69008710D7 /* Views */ = { - isa = PBXGroup; - children = ( - 6CCDA2972D306A25007CD84A /* ContentView.swift */, - 6C2265E32D306B90008710D7 /* SwiftUITextView.swift */, - 6C2265E52D306D37008710D7 /* TextViewController.swift */, - ); - path = Views; - sourceTree = ""; - }; 6CCDA2742D306A1B007CD84A = { isa = PBXGroup; children = ( 6CCDA2A12D306A5B007CD84A /* CodeEditTextView */, - 6CCDA2992D306A25007CD84A /* CodeEditTextViewExample */, + B6654F5D2DF001EB003B32B8 /* CodeEditTextViewExample */, 6CCDA2A02D306A5B007CD84A /* Frameworks */, 6CCDA27E2D306A1B007CD84A /* Products */, ); @@ -77,19 +66,6 @@ name = Products; sourceTree = ""; }; - 6CCDA2992D306A25007CD84A /* CodeEditTextViewExample */ = { - isa = PBXGroup; - children = ( - 6CCDA2952D306A25007CD84A /* CodeEditTextViewExampleApp.swift */, - 6C2265E02D306AEB008710D7 /* Documents */, - 6C2265E22D306B69008710D7 /* Views */, - 6CCDA2942D306A25007CD84A /* Assets.xcassets */, - 6CCDA2982D306A25007CD84A /* Info.plist */, - 6C2265E12D306B58008710D7 /* CodeEditTextViewExample.entitlements */, - ); - path = CodeEditTextViewExample; - sourceTree = ""; - }; 6CCDA2A02D306A5B007CD84A /* Frameworks */ = { isa = PBXGroup; children = ( @@ -112,6 +88,9 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + B6654F5D2DF001EB003B32B8 /* CodeEditTextViewExample */, + ); name = CodeEditTextViewExample; packageProductDependencies = ( 6C2265DE2D306AB7008710D7 /* CodeEditTextView */, @@ -159,7 +138,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 6CCDA29B2D306A25007CD84A /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -170,11 +148,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 6C2265E62D306D37008710D7 /* TextViewController.swift in Sources */, - 6CCDA29D2D306A25007CD84A /* CodeEditTextViewExampleApp.swift in Sources */, - 6CCDA29E2D306A25007CD84A /* CodeEditTextViewExampleDocument.swift in Sources */, - 6CCDA29F2D306A25007CD84A /* ContentView.swift in Sources */, - 6C2265E42D306B90008710D7 /* SwiftUITextView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-1024.png b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-1024.png new file mode 100644 index 000000000..86c5e4478 Binary files /dev/null and b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-1024.png differ diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-128.png b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-128.png new file mode 100644 index 000000000..af8e599cc Binary files /dev/null and b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-128.png differ diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-16.png b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-16.png new file mode 100644 index 000000000..2e3297530 Binary files /dev/null and b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-16.png differ diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-256.png b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-256.png new file mode 100644 index 000000000..b37995124 Binary files /dev/null and b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-256.png differ diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-32.png b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-32.png new file mode 100644 index 000000000..4ba6ba21d Binary files /dev/null and b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-32.png differ diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-512.png b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-512.png new file mode 100644 index 000000000..e35cfe804 Binary files /dev/null and b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-512.png differ diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-64.png b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-64.png new file mode 100644 index 000000000..e4cc199ba Binary files /dev/null and b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/CodeEditTextView-Icon-64.png differ diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/Contents.json index 230588010..6d0b8c531 100644 --- a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,35 +1,68 @@ { "images" : [ { - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "tinted" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" + "size" : "16x16", + "idiom" : "mac", + "filename" : "CodeEditTextView-Icon-16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "CodeEditTextView-Icon-32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "CodeEditTextView-Icon-32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "CodeEditTextView-Icon-64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "CodeEditTextView-Icon-128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "CodeEditTextView-Icon-256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "CodeEditTextView-Icon-256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "CodeEditTextView-Icon-512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "CodeEditTextView-Icon-512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "CodeEditTextView-Icon-1024.png", + "scale" : "2x" } ], "info" : { - "author" : "xcode", - "version" : 1 + "version" : 1, + "author" : "xcode" } -} +} \ No newline at end of file diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift index 1a64d8b54..9cd2a6021 100644 --- a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift @@ -11,18 +11,28 @@ struct ContentView: View { @Binding var document: CodeEditTextViewExampleDocument @AppStorage("wraplines") private var wrapLines: Bool = true @AppStorage("edgeinsets") private var enableEdgeInsets: Bool = false + @AppStorage("usesystemcursor") private var useSystemCursor: Bool = false + @AppStorage("isselectable") private var isSelectable: Bool = true + @AppStorage("iseditable") private var isEditable: Bool = true var body: some View { - VStack(spacing: 0) { - HStack { - Toggle("Wrap Lines", isOn: $wrapLines) - Toggle("Inset Edges", isOn: $enableEdgeInsets) - } - Divider() - SwiftUITextView( + SwiftUITextView( + text: document.text, + wrapLines: $wrapLines, + enableEdgeInsets: $enableEdgeInsets, + useSystemCursor: $useSystemCursor, + isSelectable: $isSelectable, + isEditable: $isEditable + ) + .padding(.bottom, 28) + .overlay(alignment: .bottom) { + StatusBar( text: document.text, wrapLines: $wrapLines, - enableEdgeInsets: $enableEdgeInsets + enableEdgeInsets: $enableEdgeInsets, + useSystemCursor: $useSystemCursor, + isSelectable: $isSelectable, + isEditable: $isEditable ) } } diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/StatusBar.swift b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/StatusBar.swift new file mode 100644 index 000000000..211254072 --- /dev/null +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/StatusBar.swift @@ -0,0 +1,63 @@ +// +// StatusBar.swift +// CodeEditTextViewExample +// +// Created by Austin Condiff on 6/3/25. +// + +import SwiftUI + +struct StatusBar: View { + @Environment(\.colorScheme) + var colorScheme + + var text: NSTextStorage + + @Binding var wrapLines: Bool + @Binding var enableEdgeInsets: Bool + @Binding var useSystemCursor: Bool + @Binding var isSelectable: Bool + @Binding var isEditable: Bool + + var body: some View { + HStack { + Menu { + Toggle("Wrap Lines", isOn: $wrapLines) + Toggle("Inset Edges", isOn: $enableEdgeInsets) + Toggle("Use System Cursor", isOn: $useSystemCursor) + Toggle("Selectable", isOn: $isSelectable) + Toggle("Editable", isOn: $isEditable) + } label: {} + .background { + Image(systemName: "switch.2") + .foregroundStyle(.secondary) + .font(.system(size: 13.5, weight: .regular)) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .frame(maxWidth: 18, alignment: .center) + Spacer() + Group { + Text("\(text.length) characters") + } + .foregroundStyle(.secondary) + } + .font(.subheadline) + .fontWeight(.medium) + .controlSize(.small) + .padding(.horizontal, 8) + .frame(height: 28) + .background(.bar) + .overlay(alignment: .top) { + VStack { + Divider() + .overlay { + if colorScheme == .dark { + Color.black + } + } + } + } + .zIndex(2) + } +} diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/SwiftUITextView.swift b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/SwiftUITextView.swift index 1bb836239..693826a39 100644 --- a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/SwiftUITextView.swift +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/SwiftUITextView.swift @@ -13,17 +13,26 @@ struct SwiftUITextView: NSViewControllerRepresentable { var text: NSTextStorage @Binding var wrapLines: Bool @Binding var enableEdgeInsets: Bool + @Binding var useSystemCursor: Bool + @Binding var isSelectable: Bool + @Binding var isEditable: Bool func makeNSViewController(context: Context) -> TextViewController { let controller = TextViewController(string: "") controller.textView.setTextStorage(text) controller.wrapLines = wrapLines controller.enableEdgeInsets = enableEdgeInsets + controller.useSystemCursor = useSystemCursor + controller.isSelectable = isSelectable + controller.isEditable = isEditable return controller } func updateNSViewController(_ nsViewController: TextViewController, context: Context) { nsViewController.wrapLines = wrapLines nsViewController.enableEdgeInsets = enableEdgeInsets + nsViewController.useSystemCursor = useSystemCursor + nsViewController.isSelectable = isSelectable + nsViewController.isEditable = isEditable } } diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/TextViewController.swift b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/TextViewController.swift index a880d9731..a37704c8c 100644 --- a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/TextViewController.swift +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/TextViewController.swift @@ -27,6 +27,25 @@ class TextViewController: NSViewController { textView.wrapLines = wrapLines } } + var useSystemCursor: Bool = false { + didSet { + textView.useSystemCursor = useSystemCursor + // Force cursor update by temporarily removing and re-adding the selection + if let range = textView.selectionManager.textSelections.first?.range { + textView.selectionManager.setSelectedRange(NSRange(location: range.location, length: 0)) + } + } + } + var isSelectable: Bool = true { + didSet { + textView.isSelectable = isSelectable + } + } + var isEditable: Bool = true { + didSet { + textView.isEditable = isEditable + } + } init(string: String) { textView = TextView(string: string) diff --git a/README.md b/README.md index d615c4484..8e3a36d94 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,7 @@ A text editor specialized for displaying and editing code documents. Features include basic text editing, extremely fast initial layout, support for handling large documents, customization options for code documents. ![GitHub release](https://img.shields.io/github/v/release/CodeEditApp/CodeEditTextView?color=orange&label=latest%20release&sort=semver&style=flat-square) -![Github Tests](https://img.shields.io/github/actions/workflow/status/CodeEditApp/CodeEditTextView/tests.yml?branch=main&label=tests&style=flat-square) -![Documentation](https://img.shields.io/github/actions/workflow/status/CodeEditApp/CodeEditTextView/build-documentation.yml?branch=main&label=docs&style=flat-square) +![Github Tests](https://img.shields.io/github/actions/workflow/status/CodeEditApp/CodeEditTextView/CI-push.yml?branch=main&label=tests&style=flat-square) ![GitHub Repo stars](https://img.shields.io/github/stars/CodeEditApp/CodeEditTextView?style=flat-square) ![GitHub forks](https://img.shields.io/github/forks/CodeEditApp/CodeEditTextView?style=flat-square) [![Discord Badge](https://img.shields.io/discord/951544472238444645?color=5865F2&label=Discord&logo=discord&logoColor=white&style=flat-square)](https://discord.gg/vChUXVf9Em) @@ -102,7 +101,7 @@ Special thanks to [Matt Massicotte](https://twitter.com/mattie) for the great wo - +

        CodeEdit        

diff --git a/Sources/CodeEditTextView/InvisibleCharacters/InvisibleCharactersDelegate.swift b/Sources/CodeEditTextView/InvisibleCharacters/InvisibleCharactersDelegate.swift new file mode 100644 index 000000000..9b7fa9d75 --- /dev/null +++ b/Sources/CodeEditTextView/InvisibleCharacters/InvisibleCharactersDelegate.swift @@ -0,0 +1,20 @@ +// +// InvisibleCharactersConfig.swift +// CodeEditTextView +// +// Created by Khan Winter on 6/9/25. +// + +import Foundation +import AppKit + +public enum InvisibleCharacterStyle: Hashable { + case replace(replacementCharacter: String, color: NSColor, font: NSFont) + case emphasize(color: NSColor) +} + +public protocol InvisibleCharactersDelegate: AnyObject { + var triggerCharacters: Set { get } + func invisibleStyleShouldClearCache() -> Bool + func invisibleStyle(for character: UInt16, at range: NSRange, lineRange: NSRange) -> InvisibleCharacterStyle? +} diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index 042622830..d7732e37c 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -251,9 +251,9 @@ extension TextLayoutManager { renderDelegate?.lineFragmentView(for: lineFragment.data) ?? LineFragmentView() } view.translatesAutoresizingMaskIntoConstraints = false - view.setLineFragment(lineFragment.data) + view.setLineFragment(lineFragment.data, renderer: lineFragmentRenderer) view.frame.origin = CGPoint(x: edgeInsets.left, y: yPos) - layoutView?.addSubview(view) + layoutView?.addSubview(view, positioned: .below, relativeTo: nil) view.needsDisplay = true } } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index 84195b00c..0985f53d7 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -66,12 +66,21 @@ public class TextLayoutManager: NSObject { public let attachments: TextAttachmentManager = TextAttachmentManager() + public weak var invisibleCharacterDelegate: InvisibleCharactersDelegate? { + didSet { + lineFragmentRenderer.invisibleCharacterDelegate = invisibleCharacterDelegate + layoutView?.needsDisplay = true + } + } + // MARK: - Internal weak var textStorage: NSTextStorage? var lineStorage: TextLineStorage = TextLineStorage() var markedTextManager: MarkedTextManager = MarkedTextManager() let viewReuseQueue: ViewReuseQueue = ViewReuseQueue() + let lineFragmentRenderer: LineFragmentRenderer + package var visibleLineIds: Set = [] /// Used to force a complete re-layout using `setNeedsLayout` package var needsLayout: Bool = false @@ -122,7 +131,8 @@ public class TextLayoutManager: NSObject { wrapLines: Bool, textView: NSView, delegate: TextLayoutManagerDelegate?, - renderDelegate: TextLayoutManagerRenderDelegate? = nil + renderDelegate: TextLayoutManagerRenderDelegate? = nil, + invisibleCharacterDelegate: InvisibleCharactersDelegate? = nil ) { self.textStorage = textStorage self.lineHeightMultiplier = lineHeightMultiplier @@ -130,6 +140,11 @@ public class TextLayoutManager: NSObject { self.layoutView = textView self.delegate = delegate self.renderDelegate = renderDelegate + self.lineFragmentRenderer = LineFragmentRenderer( + textStorage: textStorage, + invisibleCharacterDelegate: invisibleCharacterDelegate + ) + self.invisibleCharacterDelegate = invisibleCharacterDelegate super.init() prepareTextLines() attachments.layoutManager = self @@ -166,6 +181,7 @@ public class TextLayoutManager: NSObject { viewReuseQueue.usedViews.removeAll() maxLineWidth = 0 markedTextManager.removeAll() + lineFragmentRenderer.textStorage = textStorage prepareTextLines() setNeedsLayout() } diff --git a/Sources/CodeEditTextView/TextLine/LineFragment.swift b/Sources/CodeEditTextView/TextLine/LineFragment.swift index 1c777bcf5..6671fb8ef 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragment.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragment.swift @@ -47,6 +47,7 @@ public final class LineFragment: Identifiable, Equatable { } public let id = UUID() + public let lineRange: NSRange public let documentRange: NSRange public var contents: [FragmentContent] public var width: CGFloat @@ -60,6 +61,7 @@ public final class LineFragment: Identifiable, Equatable { } init( + lineRange: NSRange, documentRange: NSRange, contents: [FragmentContent], width: CGFloat, @@ -67,6 +69,7 @@ public final class LineFragment: Identifiable, Equatable { descent: CGFloat, lineHeightMultiplier: CGFloat ) { + self.lineRange = lineRange self.documentRange = documentRange self.contents = contents self.width = width @@ -102,52 +105,6 @@ public final class LineFragment: Identifiable, Equatable { } } - public func draw(in context: CGContext, yPos: CGFloat) { - context.saveGState() - - // Removes jagged edges - context.setAllowsAntialiasing(true) - context.setShouldAntialias(true) - - // Effectively increases the screen resolution by drawing text in each LED color pixel (R, G, or B), rather than - // the triplet of pixels (RGB) for a regular pixel. This can increase text clarity, but loses effectiveness - // in low-contrast settings. - context.setAllowsFontSubpixelPositioning(true) - context.setShouldSubpixelPositionFonts(true) - - // Quantizes the position of each glyph, resulting in slightly less accurate positioning, and gaining higher - // quality bitmaps and performance. - context.setAllowsFontSubpixelQuantization(true) - context.setShouldSubpixelQuantizeFonts(true) - - ContextSetHiddenSmoothingStyle(context, 16) - - context.textMatrix = .init(scaleX: 1, y: -1) - - var currentPosition: CGFloat = 0.0 - var currentLocation = 0 - for content in contents { - context.saveGState() - switch content.data { - case .text(let ctLine): - context.textPosition = CGPoint( - x: currentPosition, - y: yPos + height - descent + (heightDifference/2) - ).pixelAligned - CTLineDraw(ctLine, context) - case .attachment(let attachment): - attachment.attachment.draw( - in: context, - rect: NSRect(x: currentPosition, y: yPos, width: attachment.width, height: scaledHeight) - ) - } - context.restoreGState() - currentPosition += content.width - currentLocation += content.length - } - context.restoreGState() - } - package func findContent(at location: Int) -> (content: FragmentContent, position: ContentPosition)? { var position = ContentPosition(xPos: 0, offset: 0) diff --git a/Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift b/Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift new file mode 100644 index 000000000..5225123d8 --- /dev/null +++ b/Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift @@ -0,0 +1,290 @@ +// +// LineFragmentRenderer.swift +// CodeEditTextView +// +// Created by Khan Winter on 6/10/25. +// + +import AppKit +import CodeEditTextViewObjC + +/// Manages drawing line fragments into a drawing context. +public final class LineFragmentRenderer { + private struct CacheKey: Hashable { + let string: String + let font: NSFont + let color: NSColor + } + + private struct InvisibleDrawingContext { + let lineFragment: LineFragment + let ctLine: CTLine + let contentOffset: Int + let position: CGPoint + let context: CGContext + } + + weak var textStorage: NSTextStorage? + weak var invisibleCharacterDelegate: InvisibleCharactersDelegate? + private var attributedStringCache: [CacheKey: CTLine] = [:] + + /// Create a fragment renderer. + /// - Parameters: + /// - textStorage: The text storage backing the fragments being drawn. + /// - invisibleCharacterDelegate: A delegate object to interrogate for invisible character drawing. + public init(textStorage: NSTextStorage?, invisibleCharacterDelegate: InvisibleCharactersDelegate?) { + self.textStorage = textStorage + self.invisibleCharacterDelegate = invisibleCharacterDelegate + } + + /// Draw the given line fragment into a drawing context, using the invisible character configuration determined + /// from the ``invisibleCharacterDelegate``, and line fragment information from the passed ``LineFragment`` object. + /// - Parameters: + /// - lineFragment: The line fragment to drawn + /// - context: The drawing context to draw into. + /// - yPos: In the drawing context, what `y` position to start drawing at. + public func draw(lineFragment: LineFragment, in context: CGContext, yPos: CGFloat) { + if invisibleCharacterDelegate?.invisibleStyleShouldClearCache() == true { + attributedStringCache.removeAll(keepingCapacity: true) + } + + context.saveGState() + // Removes jagged edges + context.setAllowsAntialiasing(true) + context.setShouldAntialias(true) + + // Effectively increases the screen resolution by drawing text in each LED color pixel (R, G, or B), rather than + // the triplet of pixels (RGB) for a regular pixel. This can increase text clarity, but loses effectiveness + // in low-contrast settings. + context.setAllowsFontSubpixelPositioning(true) + context.setShouldSubpixelPositionFonts(true) + + // Quantizes the position of each glyph, resulting in slightly less accurate positioning, and gaining higher + // quality bitmaps and performance. + context.setAllowsFontSubpixelQuantization(true) + context.setShouldSubpixelQuantizeFonts(true) + + ContextSetHiddenSmoothingStyle(context, 16) + + context.textMatrix = .init(scaleX: 1, y: -1) + + var currentPosition: CGFloat = 0.0 + var currentLocation = 0 + for content in lineFragment.contents { + context.saveGState() + switch content.data { + case .text(let ctLine): + context.textPosition = CGPoint( + x: currentPosition, + y: yPos + lineFragment.height - lineFragment.descent + (lineFragment.heightDifference/2) + ).pixelAligned + CTLineDraw(ctLine, context) + + drawInvisibles( + lineFragment: lineFragment, + for: ctLine, + contentOffset: currentLocation, + position: CGPoint(x: currentPosition, y: yPos), + in: context + ) + case .attachment(let attachment): + attachment.attachment.draw( + in: context, + rect: NSRect( + x: currentPosition, + y: yPos, + width: attachment.width, + height: lineFragment.scaledHeight + ) + ) + } + context.restoreGState() + currentPosition += content.width + currentLocation += content.length + } + context.restoreGState() + } + + private func drawInvisibles( + lineFragment: LineFragment, + for ctLine: CTLine, + contentOffset: Int, + position: CGPoint, + in context: CGContext + ) { + guard let textStorage, let invisibleCharacterDelegate else { return } + + let drawingContext = InvisibleDrawingContext( + lineFragment: lineFragment, + ctLine: ctLine, + contentOffset: contentOffset, + position: position, + context: context + ) + + let range = createTextRange(for: drawingContext) + let string = (textStorage.string as NSString).substring(with: range) + + processInvisibleCharacters( + in: string, + range: range, + delegate: invisibleCharacterDelegate, + drawingContext: drawingContext + ) + } + + private func createTextRange(for drawingContext: InvisibleDrawingContext) -> NSRange { + return NSRange( + start: drawingContext.lineFragment.documentRange.location + drawingContext.contentOffset, + end: drawingContext.lineFragment.documentRange.max + ) + } + + private func processInvisibleCharacters( + in string: String, + range: NSRange, + delegate: InvisibleCharactersDelegate, + drawingContext: InvisibleDrawingContext + ) { + drawingContext.context.saveGState() + defer { drawingContext.context.restoreGState() } + + lazy var offset = CTLineGetStringRange(drawingContext.ctLine).location + + for (idx, character) in string.utf16.enumerated() + where delegate.triggerCharacters.contains(character) { + processInvisibleCharacter( + character: character, + at: idx, + in: range, + offset: offset, + delegate: delegate, + drawingContext: drawingContext + ) + } + } + + // Disabling the next lint warning because I *cannot* figure out how to split this up further. + + private func processInvisibleCharacter( // swiftlint:disable:this function_parameter_count + character: UInt16, + at index: Int, + in range: NSRange, + offset: Int, + delegate: InvisibleCharactersDelegate, + drawingContext: InvisibleDrawingContext + ) { + guard let style = delegate.invisibleStyle( + for: character, + at: NSRange(start: range.location + index, end: range.max), + lineRange: drawingContext.lineFragment.lineRange + ) else { + return + } + + let xOffset = CTLineGetOffsetForStringIndex(drawingContext.ctLine, offset + index, nil) + + switch style { + case let .replace(replacementCharacter, color, font): + drawReplacementCharacter( + replacementCharacter, + color: color, + font: font, + at: calculateReplacementPosition( + basePosition: drawingContext.position, + xOffset: xOffset, + lineFragment: drawingContext.lineFragment + ), + in: drawingContext.context + ) + case let .emphasize(color): + let emphasizeRect = calculateEmphasisRect( + basePosition: drawingContext.position, + xOffset: xOffset, + characterIndex: index, + offset: offset, + drawingContext: drawingContext + ) + + drawEmphasis( + color: color, + forRect: emphasizeRect, + in: drawingContext.context + ) + } + } + + private func calculateReplacementPosition( + basePosition: CGPoint, + xOffset: CGFloat, + lineFragment: LineFragment + ) -> CGPoint { + return CGPoint( + x: basePosition.x + xOffset, + y: basePosition.y + lineFragment.height - lineFragment.descent + (lineFragment.heightDifference/2) + ) + } + + private func calculateEmphasisRect( + basePosition: CGPoint, + xOffset: CGFloat, + characterIndex: Int, + offset: Int, + drawingContext: InvisibleDrawingContext + ) -> NSRect { + let xEndOffset = if offset + characterIndex + 1 == drawingContext.lineFragment.documentRange.length { + drawingContext.lineFragment.width + } else { + CTLineGetOffsetForStringIndex(drawingContext.ctLine, offset + characterIndex + 1, nil) + } + + return NSRect( + x: basePosition.x + xOffset, + y: basePosition.y, + width: xEndOffset - xOffset, + height: drawingContext.lineFragment.scaledHeight + ) + } + + private func drawReplacementCharacter( + _ replacementCharacter: String, + color: NSColor, + font: NSFont, + at position: CGPoint, + in context: CGContext + ) { + let cacheKey = CacheKey(string: replacementCharacter, font: font, color: color) + let ctLine: CTLine + if let cachedValue = attributedStringCache[cacheKey] { + ctLine = cachedValue + } else { + let attrString = NSAttributedString(string: replacementCharacter, attributes: [ + .font: font, + .foregroundColor: color + ]) + ctLine = CTLineCreateWithAttributedString(attrString) + attributedStringCache[cacheKey] = ctLine + } + context.textPosition = position + CTLineDraw(ctLine, context) + } + + private func drawEmphasis( + color: NSColor, + forRect: NSRect, + in context: CGContext + ) { + context.setFillColor(color.cgColor) + + let rect: CGRect + + if forRect.width == 0 { + // Zero-width character, add padding + rect = CGRect(x: forRect.origin.x - 2, y: forRect.origin.y, width: 4, height: forRect.height) + } else { + rect = forRect + } + + context.fill(rect) + } +} diff --git a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift index 89b9141dc..58a793306 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift @@ -10,6 +10,7 @@ import AppKit /// Displays a line fragment. open class LineFragmentView: NSView { public weak var lineFragment: LineFragment? + public weak var renderer: LineFragmentRenderer? open override var isFlipped: Bool { true @@ -29,8 +30,9 @@ open class LineFragmentView: NSView { /// Set a new line fragment for this view, updating view size. /// - Parameter newFragment: The new fragment to use. - open func setLineFragment(_ newFragment: LineFragment) { + open func setLineFragment(_ newFragment: LineFragment, renderer: LineFragmentRenderer) { self.lineFragment = newFragment + self.renderer = renderer self.frame.size = CGSize(width: newFragment.width, height: newFragment.scaledHeight) } @@ -40,6 +42,6 @@ open class LineFragmentView: NSView { return } - lineFragment.draw(in: context, yPos: 0.0) + renderer?.draw(lineFragment: lineFragment, in: context, yPos: 0.0) } } diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift index 9f0f713d9..e74af78a9 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift @@ -61,6 +61,7 @@ struct TypesetContext { /// Pop the current fragment state into a new line fragment, and reset the fragment state. mutating func popCurrentData() { let fragment = LineFragment( + lineRange: documentRange, documentRange: NSRange( location: fragmentContext.start + documentRange.location, length: currentPosition - fragmentContext.start diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift index 10c6d244e..2bebfb69d 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift @@ -230,6 +230,7 @@ final public class Typesetter { // Insert an empty fragment let ctLine = CTTypesetterCreateLine(typesetter, CFRangeMake(0, 0)) let fragment = LineFragment( + lineRange: documentRange ?? .zero, documentRange: NSRange(location: (documentRange ?? .notFound).location, length: 0), contents: [.init(data: .text(line: ctLine), width: 0.0)], width: 0, diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index 28e8801a2..deff69375 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift @@ -179,7 +179,7 @@ public class TextSelectionManager: NSObject { cursorTimer.register(internalCursorView) } - textView?.addSubview(cursorView) + textView?.addSubview(cursorView, positioned: .above, relativeTo: nil) } cursorView.frame.origin = cursorRect.origin diff --git a/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift b/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift index b8680f443..966b83b8d 100644 --- a/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift +++ b/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift @@ -70,13 +70,17 @@ class DraggingTextRenderer: NSView { yOffset: CGFloat, context: CGContext ) { + let renderer = LineFragmentRenderer( + textStorage: layoutManager.textStorage, + invisibleCharacterDelegate: layoutManager.invisibleCharacterDelegate + ) for fragment in line.data.lineFragments { guard let fragmentRange = fragment.range.shifted(by: line.range.location), fragmentRange.intersection(selectedRange) != nil else { continue } let fragmentYPos = line.yPos + fragment.yPos - yOffset - fragment.data.draw(in: context, yPos: fragmentYPos) + renderer.draw(lineFragment: fragment.data, in: context, yPos: fragmentYPos) // Clear text that's not selected if fragmentRange.contains(selectedRange.lowerBound) {