Skip to content

Commit c2fdc98

Browse files
Draw Invisible Characters (#334)
### Description Adds a configuration option to draw invisible characters. By default, draws spaces as a small dot, with emphasis on spaces that align with the user's selected indent width. Draws tabs as a small arrow. Draws line endings (carriage returns and line feeds) as a `¬` character. Replacement characters are configurable using the config object. Adds a configuration option to warn users about potentially dangerous/unnecessary characters that may not be visible to the naked eye. This could be used to warn about something like a zero-width space being accidentally copied into a file. #### Detailed changes - Adds a new `InvisibleCharactersConfig` type that contains toggles for certain whitespace characters, as well as a set of 'warning' characters to draw warning boxes on. - Adds a new `InvisibleCharactersCoordinator` that tells the text view how to draw characters depending on the current configuration. - Adds a new `invisibleCharacterConfig` option to `CodeEditSourceEditor` and `TextViewController`. - Adds a new `warningCharacters` option to `CodeEditSourceEditor` and `TextViewController`. - Updates `TextViewController` to correctly update the coordinator when the user's theme, font, or indent option are updated. ### Related Issues * CodeEditApp/CodeEditTextView#22 * #207 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots Example document with tabs, spaces, newlines, and a zero-width character pasted in before the `1`. It responds to theme updates and live editing. https://github.com/user-attachments/assets/29dc7585-c131-49a2-8ca8-afa2f3e2a554
1 parent e955486 commit c2fdc98

File tree

11 files changed

+458
-31
lines changed

11 files changed

+458
-31
lines changed

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ struct ContentView: View {
3030
@State private var indentOption: IndentOption = .spaces(count: 4)
3131
@AppStorage("reformatAtColumn") private var reformatAtColumn: Int = 80
3232
@AppStorage("showReformattingGuide") private var showReformattingGuide: Bool = false
33+
@State private var invisibleCharactersConfig: InvisibleCharactersConfig = .empty
3334

3435
init(document: Binding<CodeEditSourceEditorExampleDocument>, fileURL: URL?) {
3536
self._document = document
@@ -56,7 +57,8 @@ struct ContentView: View {
5657
useSystemCursor: useSystemCursor,
5758
showMinimap: showMinimap,
5859
reformatAtColumn: reformatAtColumn,
59-
showReformattingGuide: showReformattingGuide
60+
showReformattingGuide: showReformattingGuide,
61+
invisibleCharactersConfig: invisibleCharactersConfig
6062
)
6163
.overlay(alignment: .bottom) {
6264
StatusBar(
@@ -71,7 +73,8 @@ struct ContentView: View {
7173
showMinimap: $showMinimap,
7274
indentOption: $indentOption,
7375
reformatAtColumn: $reformatAtColumn,
74-
showReformattingGuide: $showReformattingGuide
76+
showReformattingGuide: $showReformattingGuide,
77+
invisibles: $invisibleCharactersConfig
7578
)
7679
}
7780
.ignoresSafeArea()

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ struct StatusBar: View {
2626
@Binding var indentOption: IndentOption
2727
@Binding var reformatAtColumn: Int
2828
@Binding var showReformattingGuide: Bool
29+
@Binding var invisibles: InvisibleCharactersConfig
2930

3031
var body: some View {
3132
HStack {
@@ -50,6 +51,33 @@ struct StatusBar: View {
5051
.disabled(true)
5152
.help("macOS 14 required")
5253
}
54+
55+
Menu {
56+
Toggle("Spaces", isOn: $invisibles.showSpaces)
57+
Toggle("Tabs", isOn: $invisibles.showTabs)
58+
Toggle("Line Endings", isOn: $invisibles.showLineEndings)
59+
Divider()
60+
Toggle(
61+
"Warning Characters",
62+
isOn: Binding(
63+
get: {
64+
!invisibles.warningCharacters.isEmpty
65+
},
66+
set: { newValue in
67+
// In this example app, we only add one character
68+
// For real apps, consider providing a table where users can add UTF16
69+
// char codes to warn about, as well as a set of good defaults.
70+
if newValue {
71+
invisibles.warningCharacters.insert(0x200B) // zero-width space
72+
} else {
73+
invisibles.warningCharacters.removeAll()
74+
}
75+
}
76+
)
77+
)
78+
} label: {
79+
Text("Invisibles")
80+
}
5381
} label: {}
5482
.background {
5583
Image(systemName: "switch.2")

Package.resolved

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ let package = Package(
1717
// A fast, efficient, text view for code.
1818
.package(
1919
url: "https://github.com/CodeEditApp/CodeEditTextView.git",
20-
from: "0.11.1"
20+
from: "0.11.2"
2121
),
2222
// tree-sitter languages
2323
.package(

Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,14 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
5050
/// - useSystemCursor: If true, uses the system cursor on `>=macOS 14`.
5151
/// - undoManager: The undo manager for the text view. Defaults to `nil`, which will create a new CEUndoManager
5252
/// - coordinators: Any text coordinators for the view to use. See ``TextViewCoordinator`` for more information.
53-
/// - showMinimap: Whether to show the minimap
54-
/// - reformatAtColumn: The column to reformat at
55-
/// - showReformattingGuide: Whether to show the reformatting guide
53+
/// - showMinimap: Whether to show the minimap.
54+
/// - reformatAtColumn: The column to reformat at.
55+
/// - showReformattingGuide: Whether to show the reformatting guide.
56+
/// - invisibleCharactersConfig: Configuration for displaying invisible characters. Defaults to an empty object.
57+
/// See ``TextViewController/invisibleCharactersConfig`` and
58+
/// ``InvisibleCharactersConfig`` for more information.
59+
/// - warningCharacters: A set of characters the editor should draw with a small red border. See
60+
/// ``TextViewController/warningCharacters`` for more information.
5661
public init(
5762
_ text: Binding<String>,
5863
language: CodeLanguage,
@@ -77,7 +82,9 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
7782
coordinators: [any TextViewCoordinator] = [],
7883
showMinimap: Bool,
7984
reformatAtColumn: Int,
80-
showReformattingGuide: Bool
85+
showReformattingGuide: Bool,
86+
invisibleCharactersConfig: InvisibleCharactersConfig = .empty,
87+
warningCharacters: Set<UInt16> = []
8188
) {
8289
self.text = .binding(text)
8390
self.language = language
@@ -107,6 +114,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
107114
self.showMinimap = showMinimap
108115
self.reformatAtColumn = reformatAtColumn
109116
self.showReformattingGuide = showReformattingGuide
117+
self.invisibleCharactersConfig = invisibleCharactersConfig
118+
self.warningCharacters = warningCharacters
110119
}
111120

112121
/// Initializes a Text Editor
@@ -136,9 +145,14 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
136145
/// See `BracketPairEmphasis` for more information. Defaults to `nil`
137146
/// - undoManager: The undo manager for the text view. Defaults to `nil`, which will create a new CEUndoManager
138147
/// - coordinators: Any text coordinators for the view to use. See ``TextViewCoordinator`` for more information.
139-
/// - showMinimap: Whether to show the minimap
140-
/// - reformatAtColumn: The column to reformat at
141-
/// - showReformattingGuide: Whether to show the reformatting guide
148+
/// - showMinimap: Whether to show the minimap.
149+
/// - reformatAtColumn: The column to reformat at.
150+
/// - showReformattingGuide: Whether to show the reformatting guide.
151+
/// - invisibleCharactersConfig: Configuration for displaying invisible characters. Defaults to an empty object.
152+
/// See ``TextViewController/invisibleCharactersConfig`` and
153+
/// ``InvisibleCharactersConfig`` for more information.
154+
/// - warningCharacters: A set of characters the editor should draw with a small red border. See
155+
/// ``TextViewController/warningCharacters`` for more information.
142156
public init(
143157
_ text: NSTextStorage,
144158
language: CodeLanguage,
@@ -163,7 +177,9 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
163177
coordinators: [any TextViewCoordinator] = [],
164178
showMinimap: Bool,
165179
reformatAtColumn: Int,
166-
showReformattingGuide: Bool
180+
showReformattingGuide: Bool,
181+
invisibleCharactersConfig: InvisibleCharactersConfig = .empty,
182+
warningCharacters: Set<UInt16> = []
167183
) {
168184
self.text = .storage(text)
169185
self.language = language
@@ -193,6 +209,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
193209
self.showMinimap = showMinimap
194210
self.reformatAtColumn = reformatAtColumn
195211
self.showReformattingGuide = showReformattingGuide
212+
self.invisibleCharactersConfig = invisibleCharactersConfig
213+
self.warningCharacters = warningCharacters
196214
}
197215

198216
package var text: TextAPI
@@ -219,6 +237,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
219237
package var showMinimap: Bool
220238
private var reformatAtColumn: Int
221239
private var showReformattingGuide: Bool
240+
private var invisibleCharactersConfig: InvisibleCharactersConfig
241+
private var warningCharacters: Set<UInt16>
222242

223243
public typealias NSViewControllerType = TextViewController
224244

@@ -247,7 +267,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
247267
coordinators: coordinators,
248268
showMinimap: showMinimap,
249269
reformatAtColumn: reformatAtColumn,
250-
showReformattingGuide: showReformattingGuide
270+
showReformattingGuide: showReformattingGuide,
271+
invisibleCharactersConfig: invisibleCharactersConfig
251272
)
252273
switch text {
253274
case .binding(let binding):
@@ -352,6 +373,14 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
352373
if controller.useSystemCursor != useSystemCursor {
353374
controller.useSystemCursor = useSystemCursor
354375
}
376+
377+
if controller.invisibleCharactersConfig != invisibleCharactersConfig {
378+
controller.invisibleCharactersConfig = invisibleCharactersConfig
379+
}
380+
381+
if controller.warningCharacters != warningCharacters {
382+
controller.warningCharacters = warningCharacters
383+
}
355384
}
356385

357386
private func updateThemeAndLanguage(_ controller: TextViewController) {
@@ -397,6 +426,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
397426
controller.showMinimap == showMinimap &&
398427
controller.reformatAtColumn == reformatAtColumn &&
399428
controller.showReformattingGuide == showReformattingGuide &&
429+
controller.invisibleCharactersConfig == invisibleCharactersConfig &&
430+
controller.warningCharacters == warningCharacters &&
400431
areHighlightProvidersEqual(controller: controller, coordinator: coordinator)
401432
}
402433

Sources/CodeEditSourceEditor/Controller/TextViewController.swift

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty
2727
var gutterView: GutterView!
2828
var minimapView: MinimapView!
2929

30+
/// The reformatting guide view
31+
var guideView: ReformattingGuideView! {
32+
didSet {
33+
if let oldValue = oldValue {
34+
oldValue.removeFromSuperview()
35+
}
36+
}
37+
}
38+
3039
var minimapXConstraint: NSLayoutConstraint?
3140

3241
var _undoManager: CEUndoManager!
@@ -35,6 +44,10 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty
3544
var localEvenMonitor: Any?
3645
var isPostingCursorNotification: Bool = false
3746

47+
/// Middleman between the text view to our invisible characters config, with knowledge of things like the
48+
/// user's theme and indent option to help correctly draw invisible character placeholders.
49+
var invisibleCharactersCoordinator: InvisibleCharactersCoordinator
50+
3851
/// The string contents.
3952
public var string: String {
4053
textView.string
@@ -52,6 +65,7 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty
5265
public var font: NSFont {
5366
didSet {
5467
textView.font = font
68+
invisibleCharactersCoordinator.font = font
5569
highlighter?.invalidate()
5670
}
5771
}
@@ -70,6 +84,7 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty
7084
gutterView.selectedLineTextColor = theme.text.color
7185
minimapView.setTheme(theme)
7286
guideView?.setTheme(theme)
87+
invisibleCharactersCoordinator.theme = theme
7388
}
7489
}
7590

@@ -86,6 +101,7 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty
86101
public var indentOption: IndentOption {
87102
didSet {
88103
setUpTextFormation()
104+
invisibleCharactersCoordinator.indentOption = indentOption
89105
}
90106
}
91107

@@ -256,18 +272,37 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty
256272
}
257273
}
258274

259-
/// The reformatting guide view
260-
var guideView: ReformattingGuideView! {
261-
didSet {
262-
if let oldValue = oldValue {
263-
oldValue.removeFromSuperview()
264-
}
275+
/// Configuration for drawing invisible characters.
276+
///
277+
/// See ``InvisibleCharactersConfig`` for more details.
278+
public var invisibleCharactersConfig: InvisibleCharactersConfig {
279+
get {
280+
invisibleCharactersCoordinator.config
281+
}
282+
set {
283+
invisibleCharactersCoordinator.config = newValue
284+
}
285+
}
286+
287+
/// A set of characters the editor should draw with a small red border.
288+
///
289+
/// Indicates characters that the user may not have meant to insert, such as a zero-width space: `(0x200D)` or a
290+
/// non-standard quote character: `“ (0x201C)`.
291+
public var warningCharacters: Set<UInt16> {
292+
get {
293+
invisibleCharactersCoordinator.warningCharacters
294+
}
295+
set {
296+
invisibleCharactersCoordinator.warningCharacters = newValue
265297
}
266298
}
267299

268300
// MARK: Init
269301

270-
init(
302+
// Disabling function body length warning for now. There's an open issue for combining a lot of these parameters
303+
// into a single config object.
304+
305+
init( // swiftlint:disable:this function_body_length
271306
string: String,
272307
language: CodeLanguage,
273308
font: NSFont,
@@ -291,7 +326,9 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty
291326
coordinators: [TextViewCoordinator] = [],
292327
showMinimap: Bool,
293328
reformatAtColumn: Int = 80,
294-
showReformattingGuide: Bool = false
329+
showReformattingGuide: Bool = false,
330+
invisibleCharactersConfig: InvisibleCharactersConfig = .empty,
331+
warningCharacters: Set<UInt16> = []
295332
) {
296333
self.language = language
297334
self.font = font
@@ -314,14 +351,20 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty
314351
self.showMinimap = showMinimap
315352
self.reformatAtColumn = reformatAtColumn
316353
self.showReformattingGuide = showReformattingGuide
354+
self.invisibleCharactersCoordinator = InvisibleCharactersCoordinator(
355+
config: invisibleCharactersConfig,
356+
warningCharacters: warningCharacters,
357+
indentOption: indentOption,
358+
theme: theme,
359+
font: font
360+
)
317361

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

320-
let platformGuardedSystemCursor: Bool
321-
if #available(macOS 14, *) {
322-
platformGuardedSystemCursor = useSystemCursor
364+
let platformGuardedSystemCursor: Bool = if #available(macOS 14, *) {
365+
useSystemCursor
323366
} else {
324-
platformGuardedSystemCursor = false
367+
false
325368
}
326369

327370
if let idx = highlightProviders.firstIndex(where: { $0 is TreeSitterClient }),
@@ -342,6 +385,8 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty
342385
delegate: self
343386
)
344387

388+
textView.layoutManager.invisibleCharacterDelegate = invisibleCharactersCoordinator
389+
345390
// Initialize guide view
346391
self.guideView = ReformattingGuideView(column: reformatAtColumn, isVisible: showReformattingGuide, theme: theme)
347392

@@ -391,4 +436,4 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty
391436
}
392437
localEvenMonitor = nil
393438
}
394-
}
439+
} // swiftlint:disable:this file_length

0 commit comments

Comments
 (0)