Skip to content

Commit e9bd6b0

Browse files
committed
Initial implementation and working example
1 parent b6f0694 commit e9bd6b0

File tree

13 files changed

+587
-26
lines changed

13 files changed

+587
-26
lines changed

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
## TextKit Editor
2+
3+
This is a super simple code editor made with TextKit APIs with some dumb syntax highlighting logic.
4+
5+
I have created this project to learn how `NSTextContainer`, `NSTextStorage`, `NSLayoutManager` and `UITextView` work and render text.
6+
7+
The `UITextView` has then been wrapped in a `UIViewRepresentable` view and used in a SwiftUI application
8+
9+
<p align="center">
10+
<img src="img/colored.png" height="450px">
11+
<img src="img/plain.png" height="450px">
12+
</p>

TextKitEditor.xcodeproj/project.pbxproj

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,30 @@
88

99
/* Begin PBXBuildFile section */
1010
C907279026063A8600CAFAA5 /* TextKitEditorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C907278F26063A8600CAFAA5 /* TextKitEditorApp.swift */; };
11-
C907279226063A8600CAFAA5 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C907279126063A8600CAFAA5 /* ContentView.swift */; };
11+
C907279226063A8600CAFAA5 /* EditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C907279126063A8600CAFAA5 /* EditorView.swift */; };
1212
C907279426063A8700CAFAA5 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C907279326063A8700CAFAA5 /* Assets.xcassets */; };
1313
C907279726063A8700CAFAA5 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C907279626063A8700CAFAA5 /* Preview Assets.xcassets */; };
14+
C907279F26063A9600CAFAA5 /* TextContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C907279E26063A9600CAFAA5 /* TextContainer.swift */; };
15+
C90727A12606677200CAFAA5 /* CodeString.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90727A02606677200CAFAA5 /* CodeString.swift */; };
16+
C90727A32606679300CAFAA5 /* TextStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90727A22606679300CAFAA5 /* TextStorage.swift */; };
17+
C90727A5260667B600CAFAA5 /* LayoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90727A4260667B600CAFAA5 /* LayoutManager.swift */; };
18+
C90727A826066FB300CAFAA5 /* CodeTypeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90727A726066FB300CAFAA5 /* CodeTypeText.swift */; };
19+
C90727AB2606A8F100CAFAA5 /* sample.txt in Resources */ = {isa = PBXBuildFile; fileRef = C90727AA2606A8F100CAFAA5 /* sample.txt */; };
1420
/* End PBXBuildFile section */
1521

1622
/* Begin PBXFileReference section */
1723
C907278C26063A8600CAFAA5 /* TextKitEditor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TextKitEditor.app; sourceTree = BUILT_PRODUCTS_DIR; };
1824
C907278F26063A8600CAFAA5 /* TextKitEditorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextKitEditorApp.swift; sourceTree = "<group>"; };
19-
C907279126063A8600CAFAA5 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
25+
C907279126063A8600CAFAA5 /* EditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorView.swift; sourceTree = "<group>"; };
2026
C907279326063A8700CAFAA5 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
2127
C907279626063A8700CAFAA5 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
2228
C907279826063A8700CAFAA5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
29+
C907279E26063A9600CAFAA5 /* TextContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextContainer.swift; sourceTree = "<group>"; };
30+
C90727A02606677200CAFAA5 /* CodeString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeString.swift; sourceTree = "<group>"; };
31+
C90727A22606679300CAFAA5 /* TextStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextStorage.swift; sourceTree = "<group>"; };
32+
C90727A4260667B600CAFAA5 /* LayoutManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutManager.swift; sourceTree = "<group>"; };
33+
C90727A726066FB300CAFAA5 /* CodeTypeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeTypeText.swift; sourceTree = "<group>"; };
34+
C90727AA2606A8F100CAFAA5 /* sample.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = sample.txt; sourceTree = "<group>"; };
2335
/* End PBXFileReference section */
2436

2537
/* Begin PBXFrameworksBuildPhase section */
@@ -52,11 +64,12 @@
5264
C907278E26063A8600CAFAA5 /* TextKitEditor */ = {
5365
isa = PBXGroup;
5466
children = (
67+
C90727A92606A8C600CAFAA5 /* Supporting Files */,
5568
C907278F26063A8600CAFAA5 /* TextKitEditorApp.swift */,
56-
C907279126063A8600CAFAA5 /* ContentView.swift */,
5769
C907279326063A8700CAFAA5 /* Assets.xcassets */,
5870
C907279826063A8700CAFAA5 /* Info.plist */,
5971
C907279526063A8700CAFAA5 /* Preview Content */,
72+
C90727A62606680E00CAFAA5 /* Elements */,
6073
);
6174
path = TextKitEditor;
6275
sourceTree = "<group>";
@@ -69,6 +82,27 @@
6982
path = "Preview Content";
7083
sourceTree = "<group>";
7184
};
85+
C90727A62606680E00CAFAA5 /* Elements */ = {
86+
isa = PBXGroup;
87+
children = (
88+
C907279126063A8600CAFAA5 /* EditorView.swift */,
89+
C907279E26063A9600CAFAA5 /* TextContainer.swift */,
90+
C90727A02606677200CAFAA5 /* CodeString.swift */,
91+
C90727A22606679300CAFAA5 /* TextStorage.swift */,
92+
C90727A4260667B600CAFAA5 /* LayoutManager.swift */,
93+
C90727A726066FB300CAFAA5 /* CodeTypeText.swift */,
94+
);
95+
path = Elements;
96+
sourceTree = "<group>";
97+
};
98+
C90727A92606A8C600CAFAA5 /* Supporting Files */ = {
99+
isa = PBXGroup;
100+
children = (
101+
C90727AA2606A8F100CAFAA5 /* sample.txt */,
102+
);
103+
path = "Supporting Files";
104+
sourceTree = "<group>";
105+
};
72106
/* End PBXGroup section */
73107

74108
/* Begin PBXNativeTarget section */
@@ -127,6 +161,7 @@
127161
buildActionMask = 2147483647;
128162
files = (
129163
C907279726063A8700CAFAA5 /* Preview Assets.xcassets in Resources */,
164+
C90727AB2606A8F100CAFAA5 /* sample.txt in Resources */,
130165
C907279426063A8700CAFAA5 /* Assets.xcassets in Resources */,
131166
);
132167
runOnlyForDeploymentPostprocessing = 0;
@@ -138,8 +173,13 @@
138173
isa = PBXSourcesBuildPhase;
139174
buildActionMask = 2147483647;
140175
files = (
141-
C907279226063A8600CAFAA5 /* ContentView.swift in Sources */,
176+
C90727A32606679300CAFAA5 /* TextStorage.swift in Sources */,
177+
C90727A12606677200CAFAA5 /* CodeString.swift in Sources */,
178+
C907279226063A8600CAFAA5 /* EditorView.swift in Sources */,
142179
C907279026063A8600CAFAA5 /* TextKitEditorApp.swift in Sources */,
180+
C90727A5260667B600CAFAA5 /* LayoutManager.swift in Sources */,
181+
C907279F26063A9600CAFAA5 /* TextContainer.swift in Sources */,
182+
C90727A826066FB300CAFAA5 /* CodeTypeText.swift in Sources */,
143183
);
144184
runOnlyForDeploymentPostprocessing = 0;
145185
};

TextKitEditor/ContentView.swift

Lines changed: 0 additions & 21 deletions
This file was deleted.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
//
2+
// TKECodeString.swift
3+
// TextKitEditor
4+
//
5+
// Created by Mattia Righetti on 20/03/21.
6+
//
7+
8+
import Foundation
9+
import UIKit
10+
11+
class CodeString: NSString {
12+
var imp: NSMutableString
13+
14+
override init() {
15+
imp = NSMutableString()
16+
super.init()
17+
}
18+
19+
required init?(coder: NSCoder) {
20+
fatalError("init(coder:) has not been implemented")
21+
}
22+
23+
// MARK: - String Accessors
24+
25+
override var length: Int {
26+
imp.length
27+
}
28+
29+
override func character(at index: Int) -> unichar {
30+
imp.character(at: index)
31+
}
32+
33+
override func getCharacters(_ buffer: UnsafeMutablePointer<unichar>, range: NSRange) {
34+
imp.getCharacters(buffer, range: range)
35+
}
36+
37+
func replaceCharacters(in range: NSRange, with aString: String) {
38+
imp.replaceCharacters(in: range, with: aString)
39+
}
40+
41+
// MARK: - Code Intelligence
42+
43+
func enumerateCode(in range: NSRange, usingBlock block: @escaping (_ range: NSRange, _ type: CodeType) -> Void) {
44+
assert(NSEqualRanges(range, paragraphRange(for: range)), "Invalid parameter not satisfying: NSEqualRanges(range, paragraphRange(for: range))")
45+
// Enumerate lines
46+
enumerateSubstrings(in: range, options: .byParagraphs, using: { [unowned self] paragraph, substringRange, enclosingRange, stop in
47+
// detect comments
48+
if (paragraph!.trimmingCharacters(in: .whitespaces).hasPrefix("//")) {
49+
block(enclosingRange, .Comment)
50+
return
51+
}
52+
53+
// detect comments
54+
if (paragraph!.hasPrefix("#")) {
55+
block(enclosingRange, .Comment)
56+
return
57+
}
58+
59+
// Detect keywords
60+
enumerateSubstrings(in: enclosingRange, options: .byWords, using: { word, innerSubstringRange, innerEnclosingRange, stop in
61+
// Substring is a keyword
62+
if ["int", "const", "char", "return"].contains(word) {
63+
// Text before keyword is just text
64+
if innerEnclosingRange.location < innerSubstringRange.location {
65+
block(NSMakeRange(innerEnclosingRange.location, innerSubstringRange.location - innerEnclosingRange.location), .Text)
66+
}
67+
// Keyword is a keyword
68+
block(innerSubstringRange, .Keyword)
69+
// Text behind keyword is just text
70+
if NSMaxRange(innerEnclosingRange) > NSMaxRange(innerSubstringRange) {
71+
block(NSMakeRange(NSMaxRange(innerSubstringRange), NSMaxRange(innerEnclosingRange) - NSMaxRange(innerSubstringRange)), .Text)
72+
}
73+
} else {
74+
block(innerEnclosingRange, .Text)
75+
}
76+
})
77+
})
78+
}
79+
80+
func paragraphNumberForParagraph(at index: Int) -> Int {
81+
// WARNING: extremely inefficient implementation, should better be cached
82+
var number = 1
83+
enumerateSubstrings(in: NSMakeRange(0, index), options: [.byParagraphs, .substringNotRequired], using: { substring, substringRange, enclosingRange, stop in
84+
number += 1
85+
})
86+
return number
87+
}
88+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//
2+
// TKECodeTypeText.swift
3+
// TextKitEditor
4+
//
5+
// Created by Mattia Righetti on 20/03/21.
6+
//
7+
8+
import Foundation
9+
10+
enum CodeType: Int {
11+
case Text
12+
case Comment
13+
case Pragma
14+
case Keyword
15+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
//
2+
// ContentView.swift
3+
// TextKitEditor
4+
//
5+
// Created by Mattia Righetti on 20/03/21.
6+
//
7+
8+
import SwiftUI
9+
10+
struct EditorView: UIViewRepresentable {
11+
let code: String
12+
13+
func makeUIView(context: Context) -> UITextView {
14+
let codeString = CodeString()
15+
let textStorage = TextStorage()
16+
textStorage.content = codeString
17+
textStorage.font = UIFont(name: "Menlo", size: 13)!
18+
19+
let layoutManager = LayoutManager()
20+
textStorage.addLayoutManager(layoutManager)
21+
layoutManager.lineHeight = 1
22+
layoutManager.showParagraphNumbers = true
23+
layoutManager.tabWidth = 4
24+
25+
let textContainer = TextContainer()
26+
layoutManager.addTextContainer(textContainer)
27+
28+
let view = UITextView(frame: CGRect(origin: .zero, size: .zero), textContainer: textContainer)
29+
view.translatesAutoresizingMaskIntoConstraints = false
30+
view.keyboardDismissMode = .interactive
31+
view.spellCheckingType = .no
32+
view.text = code
33+
return view
34+
}
35+
36+
func updateUIView(_ uiView: UITextView, context: Context) {}
37+
}
38+
39+
struct TKEView_Previews: PreviewProvider {
40+
static var previews: some View {
41+
let codeString = """
42+
//
43+
// ContentView.swift
44+
// TextKitEditor
45+
//
46+
// Created by Mattia Righetti on 20/03/21.
47+
//
48+
49+
import SwiftUI
50+
51+
struct TKEView: UIViewRepresentable {
52+
let code: String
53+
54+
func makeUIView(context: Context) -> UITextView {
55+
let codeString = TKECodeString()
56+
let textStorage = TKETextStorage()
57+
textStorage.content = codeString
58+
textStorage.font = UIFont(name: "Menlo", size: 13)
59+
60+
let layoutManager = TKELayoutManager()
61+
layoutManager.lineHeight = 1
62+
layoutManager.showParagraphNumbers = true
63+
layoutManager.tabWidth = 4
64+
textStorage.addLayoutManager(layoutManager)
65+
66+
let textContainer = TKETextContainer()
67+
layoutManager.addTextContainer(textContainer)
68+
69+
let view = UITextView(frame: CGRect(origin: .zero, size: .zero), textContainer: textContainer)
70+
view.translatesAutoresizingMaskIntoConstraints = false
71+
view.keyboardDismissMode = .interactive
72+
view.spellCheckingType = .no
73+
return view
74+
}
75+
76+
func updateUIView(_ uiView: UITextView, context: Context) {}
77+
}
78+
"""
79+
EditorView(code: codeString)
80+
}
81+
}

0 commit comments

Comments
 (0)