Skip to content

Commit 666d33b

Browse files
Implement Invisible Characters Setting (#2065)
### Description Implements invisible character drawing in CodeEdit, including warning characters for ambiguous or invisible characters. This is stored in a new submenu in the Text Editing settings page. I decided a pane like this was the best option, as a table cannot grow to fit it's contents or scroll when it's placed in some Forms (like our settings pages for some reason). The table also takes up a lot of space, so I felt it was a good use of a sheet. ### Related Issues * closes CodeEditApp/CodeEditTextView#22 * closes CodeEditApp/CodeEditSourceEditor#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 https://github.com/user-attachments/assets/9d3b59c8-c506-471d-8ab6-840860e0c526
1 parent edc874e commit 666d33b

File tree

9 files changed

+484
-30
lines changed

9 files changed

+484
-30
lines changed

CodeEdit/Features/CodeEditUI/Views/KeyValueTable.swift

Lines changed: 77 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ struct KeyValueItem: Identifiable, Equatable {
1313
let value: String
1414
}
1515

16-
private struct NewListTableItemView: View {
16+
private struct NewListTableItemView<HeaderView: View>: View {
1717
@Environment(\.dismiss)
1818
var dismiss
1919

@@ -24,17 +24,21 @@ private struct NewListTableItemView: View {
2424
let valueColumnName: String
2525
let newItemInstruction: String
2626
let validKeys: [String]
27-
let headerView: AnyView?
27+
let headerView: HeaderView?
2828
var completion: (String, String) -> Void
2929

3030
init(
31+
key: String? = nil,
32+
value: String? = nil,
3133
_ keyColumnName: String,
3234
_ valueColumnName: String,
3335
_ newItemInstruction: String,
3436
validKeys: [String],
35-
headerView: AnyView? = nil,
37+
headerView: HeaderView? = nil,
3638
completion: @escaping (String, String) -> Void
3739
) {
40+
self.key = key ?? ""
41+
self.value = value ?? ""
3842
self.keyColumnName = keyColumnName
3943
self.valueColumnName = valueColumnName
4044
self.newItemInstruction = newItemInstruction
@@ -62,7 +66,11 @@ private struct NewListTableItemView: View {
6266
TextField(valueColumnName, text: $value)
6367
.textFieldStyle(.plain)
6468
} header: {
65-
headerView
69+
if HeaderView.self == EmptyView.self {
70+
Text(newItemInstruction)
71+
} else {
72+
headerView
73+
}
6674
}
6775
}
6876
.formStyle(.grouped)
@@ -94,17 +102,18 @@ private struct NewListTableItemView: View {
94102
}
95103
}
96104

97-
struct KeyValueTable<Header: View>: View {
105+
struct KeyValueTable<Header: View, ActionBarView: View>: View {
98106
@Binding var items: [String: String]
99107

100108
let validKeys: [String]
101109
let keyColumnName: String
102110
let valueColumnName: String
103111
let newItemInstruction: String
104-
let header: () -> Header
112+
let newItemHeader: () -> Header
113+
let actionBarTrailing: () -> ActionBarView
105114

106-
@State private var showingModal = false
107-
@State private var selection: UUID?
115+
@State private var editingItem: KeyValueItem?
116+
@State private var selection: Set<UUID> = []
108117
@State private var tableItems: [KeyValueItem] = []
109118

110119
init(
@@ -113,14 +122,16 @@ struct KeyValueTable<Header: View>: View {
113122
keyColumnName: String,
114123
valueColumnName: String,
115124
newItemInstruction: String,
116-
@ViewBuilder header: @escaping () -> Header = { EmptyView() }
125+
@ViewBuilder newItemHeader: @escaping () -> Header = { EmptyView() },
126+
@ViewBuilder actionBarTrailing: @escaping () -> ActionBarView = { EmptyView() }
117127
) {
118128
self._items = items
119129
self.validKeys = validKeys
120130
self.keyColumnName = keyColumnName
121131
self.valueColumnName = valueColumnName
122132
self.newItemInstruction = newItemInstruction
123-
self.header = header
133+
self.newItemHeader = newItemHeader
134+
self.actionBarTrailing = actionBarTrailing
124135
}
125136

126137
var body: some View {
@@ -132,11 +143,24 @@ struct KeyValueTable<Header: View>: View {
132143
Text(item.value)
133144
}
134145
}
135-
.frame(height: 200)
146+
.contextMenu(
147+
forSelectionType: UUID.self,
148+
menu: { selectedItems in
149+
Button("Edit") {
150+
editItem(id: selectedItems.first)
151+
}
152+
Button("Remove") {
153+
removeItem(selectedItems)
154+
}
155+
},
156+
primaryAction: { selectedItems in
157+
editItem(id: selectedItems.first)
158+
}
159+
)
136160
.actionBar {
137161
HStack(spacing: 2) {
138162
Button {
139-
showingModal = true
163+
editingItem = KeyValueItem(key: "", value: "")
140164
} label: {
141165
Image(systemName: "plus")
142166
}
@@ -149,38 +173,64 @@ struct KeyValueTable<Header: View>: View {
149173
} label: {
150174
Image(systemName: "minus")
151175
}
152-
.disabled(selection == nil)
153-
.opacity(selection == nil ? 0.5 : 1)
176+
.disabled(selection.isEmpty)
177+
.opacity(selection.isEmpty ? 0.5 : 1)
178+
179+
Spacer()
180+
181+
actionBarTrailing()
154182
}
155-
Spacer()
156183
}
157-
.sheet(isPresented: $showingModal) {
184+
.sheet(item: $editingItem) { item in
158185
NewListTableItemView(
186+
key: item.key,
187+
value: item.value,
159188
keyColumnName,
160189
valueColumnName,
161190
newItemInstruction,
162191
validKeys: validKeys,
163-
headerView: AnyView(header())
192+
headerView: newItemHeader()
164193
) { key, value in
165194
items[key] = value
166-
updateTableItems()
167-
showingModal = false
195+
editingItem = nil
168196
}
169197
}
170198
.cornerRadius(6)
171-
.onAppear(perform: updateTableItems)
199+
.onAppear {
200+
updateTableItems(items)
201+
if let first = tableItems.first?.id {
202+
selection = [first]
203+
}
204+
selection = []
205+
}
206+
.onChange(of: items) { newValue in
207+
updateTableItems(newValue)
208+
}
172209
}
173210

174-
private func updateTableItems() {
175-
tableItems = items.map { KeyValueItem(key: $0.key, value: $0.value) }
211+
private func updateTableItems(_ newValue: [String: String]) {
212+
tableItems = items
213+
.sorted { $0.key < $1.key }
214+
.map { KeyValueItem(key: $0.key, value: $0.value) }
176215
}
177216

178217
private func removeItem() {
179-
guard let selectedId = selection else { return }
180-
if let selectedItem = tableItems.first(where: { $0.id == selectedId }) {
181-
items.removeValue(forKey: selectedItem.key)
182-
updateTableItems()
218+
removeItem(selection)
219+
self.selection.removeAll()
220+
}
221+
222+
private func removeItem(_ selection: Set<UUID>) {
223+
for selectedId in selection {
224+
if let selectedItem = tableItems.first(where: { $0.id == selectedId }) {
225+
items.removeValue(forKey: selectedItem.key)
226+
}
227+
}
228+
}
229+
230+
private func editItem(id: UUID?) {
231+
guard let id, let item = tableItems.first(where: { $0.id == id }) else {
232+
return
183233
}
184-
selection = nil
234+
editingItem = item
185235
}
186236
}

CodeEdit/Features/Editor/Views/CodeFileView.swift

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ struct CodeFileView: View {
5757
var reformatAtColumn
5858
@AppSettings(\.textEditing.showReformattingGuide)
5959
var showReformattingGuide
60+
@AppSettings(\.textEditing.invisibleCharacters)
61+
var invisibleCharactersConfiguration
62+
@AppSettings(\.textEditing.warningCharacters)
63+
var warningCharacters
6064

6165
@Environment(\.colorScheme)
6266
private var colorScheme
@@ -139,8 +143,8 @@ struct CodeFileView: View {
139143
showMinimap: showMinimap,
140144
showReformattingGuide: showReformattingGuide,
141145
showFoldingRibbon: showFoldingRibbon,
142-
invisibleCharactersConfiguration: .empty,
143-
warningCharacters: []
146+
invisibleCharactersConfiguration: invisibleCharactersConfiguration.textViewOption(),
147+
warningCharacters: Set(warningCharacters.characters.keys)
144148
)
145149
),
146150
state: $editorState,
@@ -208,3 +212,23 @@ private extension SettingsData.TextEditingSettings.IndentOption {
208212
}
209213
}
210214
}
215+
216+
private extension SettingsData.TextEditingSettings.InvisibleCharactersConfig {
217+
func textViewOption() -> InvisibleCharactersConfiguration {
218+
guard self.enabled else { return .empty }
219+
var config = InvisibleCharactersConfiguration(
220+
showSpaces: self.showSpaces,
221+
showTabs: self.showTabs,
222+
showLineEndings: self.showLineEndings
223+
)
224+
225+
config.spaceReplacement = self.spaceReplacement
226+
config.tabReplacement = self.tabReplacement
227+
config.carriageReturnReplacement = self.carriageReturnReplacement
228+
config.lineFeedReplacement = self.lineFeedReplacement
229+
config.paragraphSeparatorReplacement = self.paragraphSeparatorReplacement
230+
config.lineSeparatorReplacement = self.lineSeparatorReplacement
231+
232+
return config
233+
}
234+
}

CodeEdit/Features/Settings/Pages/DeveloperSettings/DeveloperSettingsView.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ struct DeveloperSettingsView: View {
3434
Text(
3535
"Specify the absolute path to your LSP binary and its associated language."
3636
)
37+
} actionBarTrailing: {
38+
EmptyView()
3739
}
40+
.frame(minHeight: 96)
3841
} header: {
3942
Text("LSP Binaries")
4043
Text("Specify the language and the absolute path to the language server binary.")
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
//
2+
// InvisiblesSettingsView.swift
3+
// CodeEdit
4+
//
5+
// Created by Khan Winter on 6/13/25.
6+
//
7+
8+
import SwiftUI
9+
10+
struct InvisiblesSettingsView: View {
11+
typealias Config = SettingsData.TextEditingSettings.InvisibleCharactersConfig
12+
13+
@Binding var invisibleCharacters: Config
14+
15+
@Environment(\.dismiss)
16+
private var dismiss
17+
18+
var body: some View {
19+
VStack(spacing: 0) {
20+
Form {
21+
Section {
22+
VStack {
23+
Toggle(isOn: $invisibleCharacters.showSpaces) { Text("Show Spaces") }
24+
if invisibleCharacters.showSpaces {
25+
TextField(
26+
text: $invisibleCharacters.spaceReplacement,
27+
prompt: Text("Default: \(Config.default.spaceReplacement)")
28+
) {
29+
Text("Character used to render spaces")
30+
.foregroundStyle(.secondary)
31+
.font(.caption)
32+
}
33+
.autocorrectionDisabled()
34+
}
35+
}
36+
37+
VStack {
38+
Toggle(isOn: $invisibleCharacters.showTabs) { Text("Show Tabs") }
39+
if invisibleCharacters.showTabs {
40+
TextField(
41+
text: $invisibleCharacters.tabReplacement,
42+
prompt: Text("Default: \(Config.default.tabReplacement)")
43+
) {
44+
Text("Character used to render tabs")
45+
.foregroundStyle(.secondary)
46+
.font(.caption)
47+
}
48+
.autocorrectionDisabled()
49+
}
50+
}
51+
52+
VStack {
53+
Toggle(isOn: $invisibleCharacters.showLineEndings) { Text("Show Line Endings") }
54+
if invisibleCharacters.showLineEndings {
55+
TextField(
56+
text: $invisibleCharacters.lineFeedReplacement,
57+
prompt: Text("Default: \(Config.default.lineFeedReplacement)")
58+
) {
59+
Text("Character used to render line feeds (\\n)")
60+
.foregroundStyle(.secondary)
61+
.font(.caption)
62+
}
63+
.autocorrectionDisabled()
64+
65+
TextField(
66+
text: $invisibleCharacters.carriageReturnReplacement,
67+
prompt: Text("Default: \(Config.default.carriageReturnReplacement)")
68+
) {
69+
Text("Character used to render carriage returns (Microsoft-style line endings)")
70+
.foregroundStyle(.secondary)
71+
.font(.caption)
72+
}
73+
.autocorrectionDisabled()
74+
75+
TextField(
76+
text: $invisibleCharacters.paragraphSeparatorReplacement,
77+
prompt: Text("Default: \(Config.default.paragraphSeparatorReplacement)")
78+
) {
79+
Text("Character used to render paragraph separators")
80+
.foregroundStyle(.secondary)
81+
.font(.caption)
82+
}
83+
.autocorrectionDisabled()
84+
85+
TextField(
86+
text: $invisibleCharacters.lineSeparatorReplacement,
87+
prompt: Text("Default: \(Config.default.lineSeparatorReplacement)")
88+
) {
89+
Text("Character used to render line separators")
90+
.foregroundStyle(.secondary)
91+
.font(.caption)
92+
}
93+
.autocorrectionDisabled()
94+
}
95+
}
96+
} header: {
97+
Text("Invisible Characters")
98+
Text("Toggle whitespace symbols CodeEdit will render with replacement characters.")
99+
}
100+
.textFieldStyle(.roundedBorder)
101+
}
102+
.formStyle(.grouped)
103+
Divider()
104+
HStack {
105+
Spacer()
106+
Button {
107+
dismiss()
108+
} label: {
109+
Text("Done")
110+
.frame(minWidth: 56)
111+
}
112+
.buttonStyle(.borderedProminent)
113+
}
114+
.padding()
115+
}
116+
}
117+
}

0 commit comments

Comments
 (0)