diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 448a0cc39..24a7236dd 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 6C0824A12C5C0C9700A0751E /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 6C0824A02C5C0C9700A0751E /* SwiftTerm */; }; 6C147C4529A329350089B630 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 6C147C4429A329350089B630 /* OrderedCollections */; }; 6C4E37FC2C73E00700AEE7B5 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 6C4E37FB2C73E00700AEE7B5 /* SwiftTerm */; }; + 6C50EF3B2DFC83E4007FE626 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C50EF3A2DFC83E4007FE626 /* CodeEditSourceEditor */; }; 6C66C31329D05CDC00DE9ED2 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 6C66C31229D05CDC00DE9ED2 /* GRDB */; }; 6C6BD6F429CD142C00235D17 /* CollectionConcurrencyKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C6BD6F329CD142C00235D17 /* CollectionConcurrencyKit */; }; 6C6BD6F829CD14D100235D17 /* CodeEditKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C6BD6F729CD14D100235D17 /* CodeEditKit */; }; @@ -184,6 +185,7 @@ 6C0824A12C5C0C9700A0751E /* SwiftTerm in Frameworks */, 6C81916B29B41DD300B75C92 /* DequeModule in Frameworks */, 6CB94D032CA1205100E8651C /* AsyncAlgorithms in Frameworks */, + 6C50EF3B2DFC83E4007FE626 /* CodeEditSourceEditor in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -317,6 +319,7 @@ 6CB94D022CA1205100E8651C /* AsyncAlgorithms */, 6CC00A8A2CBEF150004E8134 /* CodeEditSourceEditor */, 6C73A6D22D4F1E550012D95C /* CodeEditSourceEditor */, + 6C50EF3A2DFC83E4007FE626 /* CodeEditSourceEditor */, ); productName = CodeEdit; productReference = B658FB2C27DA9E0F00EA4DBD /* CodeEdit.app */; @@ -419,7 +422,7 @@ 303E88462C276FD600EEA8D9 /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */, 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */, 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, - 6CF368562DBBD274006A77FD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, + 6C50EF392DFC83E4007FE626 /* XCLocalSwiftPackageReference "../CodeEditSourceEditor" */, ); preferredProjectObjectVersion = 55; productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */; @@ -1616,6 +1619,13 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 6C50EF392DFC83E4007FE626 /* XCLocalSwiftPackageReference "../CodeEditSourceEditor" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../CodeEditSourceEditor; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ 2816F592280CF50500DD548B /* XCRemoteSwiftPackageReference "CodeEditSymbols" */ = { isa = XCRemoteSwiftPackageReference; @@ -1745,14 +1755,6 @@ version = 1.0.1; }; }; - 6CF368562DBBD274006A77FD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor"; - requirement = { - kind = exactVersion; - version = 0.13.2; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1800,6 +1802,10 @@ package = 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */; productName = SwiftTerm; }; + 6C50EF3A2DFC83E4007FE626 /* CodeEditSourceEditor */ = { + isa = XCSwiftPackageProductDependency; + productName = CodeEditSourceEditor; + }; 6C66C31229D05CDC00DE9ED2 /* GRDB */ = { isa = XCSwiftPackageProductDependency; package = 6C66C31129D05CC800DE9ED2 /* XCRemoteSwiftPackageReference "GRDB.swift" */; diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e0aa451da..f47ce567c 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ac57a6899925c3e4ac6d43aed791c845c6fc24a4441b6a10297a207d951b7836", + "originHash" : "124bcede2a81c31eed29e7b08d1f4b5324339e73b7fc32fa7eb70ef33058a6ca", "pins" : [ { "identity" : "anycodable", @@ -28,15 +28,6 @@ "version" : "0.1.20" } }, - { - "identity" : "codeeditsourceeditor", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditSourceEditor", - "state" : { - "revision" : "30eb8a8cf3b291c91da04cfbc6683bee643b86a6", - "version" : "0.13.2" - } - }, { "identity" : "codeeditsymbols", "kind" : "remoteSourceControl", @@ -51,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "69282e2ea7ad8976b062b945d575da47b61ed208", - "version" : "0.11.1" + "revision" : "c045ffcf4d8b2904e7bd138cdeccda99cba3ab3c", + "version" : "0.11.2" } }, { @@ -222,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/lukepistrol/SwiftLintPlugin", "state" : { - "revision" : "3780efccceaa87f17ec39638a9d263d0e742b71c", - "version" : "0.59.1" + "revision" : "3825ebf8d55bb877c91bc897e8e3d0c001f16fba", + "version" : "0.58.2" } }, { @@ -239,8 +230,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/SwiftTreeSitter.git", "state" : { - "revision" : "36aa61d1b531f744f35229f010efba9c6d6cbbdd", - "version" : "0.9.0" + "revision" : "08ef81eb8620617b55b08868126707ad72bf754f", + "version" : "0.25.0" } }, { @@ -275,8 +266,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/tree-sitter/tree-sitter", "state" : { - "revision" : "d97db6d63507eb62c536bcb2c4ac7d70c8ec665e", - "version" : "0.23.2" + "revision" : "bf655c0beaf4943573543fa77c58e8006ff34971", + "version" : "0.25.6" } } ], diff --git a/CodeEdit/Features/CodeEditUI/Views/KeyValueTable.swift b/CodeEdit/Features/CodeEditUI/Views/KeyValueTable.swift index c2cb41965..4909417ee 100644 --- a/CodeEdit/Features/CodeEditUI/Views/KeyValueTable.swift +++ b/CodeEdit/Features/CodeEditUI/Views/KeyValueTable.swift @@ -13,7 +13,7 @@ struct KeyValueItem: Identifiable, Equatable { let value: String } -private struct NewListTableItemView: View { +private struct NewListTableItemView: View { @Environment(\.dismiss) var dismiss @@ -24,17 +24,21 @@ private struct NewListTableItemView: View { let valueColumnName: String let newItemInstruction: String let validKeys: [String] - let headerView: AnyView? + let headerView: HeaderView? var completion: (String, String) -> Void init( + key: String? = nil, + value: String? = nil, _ keyColumnName: String, _ valueColumnName: String, _ newItemInstruction: String, validKeys: [String], - headerView: AnyView? = nil, + headerView: HeaderView? = nil, completion: @escaping (String, String) -> Void ) { + self.key = key ?? "" + self.value = value ?? "" self.keyColumnName = keyColumnName self.valueColumnName = valueColumnName self.newItemInstruction = newItemInstruction @@ -62,7 +66,11 @@ private struct NewListTableItemView: View { TextField(valueColumnName, text: $value) .textFieldStyle(.plain) } header: { - headerView + if HeaderView.self == EmptyView.self { + Text(newItemInstruction) + } else { + headerView + } } } .formStyle(.grouped) @@ -94,17 +102,18 @@ private struct NewListTableItemView: View { } } -struct KeyValueTable: View { +struct KeyValueTable: View { @Binding var items: [String: String] let validKeys: [String] let keyColumnName: String let valueColumnName: String let newItemInstruction: String - let header: () -> Header + let newItemHeader: () -> Header + let actionBarTrailing: () -> ActionBarView - @State private var showingModal = false - @State private var selection: UUID? + @State private var editingItem: KeyValueItem? + @State private var selection: Set = [] @State private var tableItems: [KeyValueItem] = [] init( @@ -113,14 +122,16 @@ struct KeyValueTable: View { keyColumnName: String, valueColumnName: String, newItemInstruction: String, - @ViewBuilder header: @escaping () -> Header = { EmptyView() } + @ViewBuilder newItemHeader: @escaping () -> Header = { EmptyView() }, + @ViewBuilder actionBarTrailing: @escaping () -> ActionBarView = { EmptyView() } ) { self._items = items self.validKeys = validKeys self.keyColumnName = keyColumnName self.valueColumnName = valueColumnName self.newItemInstruction = newItemInstruction - self.header = header + self.newItemHeader = newItemHeader + self.actionBarTrailing = actionBarTrailing } var body: some View { @@ -132,11 +143,24 @@ struct KeyValueTable: View { Text(item.value) } } - .frame(height: 200) + .contextMenu( + forSelectionType: UUID.self, + menu: { selectedItems in + Button("Edit") { + editItem(id: selectedItems.first) + } + Button("Remove") { + removeItem(selectedItems) + } + }, + primaryAction: { selectedItems in + editItem(id: selectedItems.first) + } + ) .actionBar { HStack(spacing: 2) { Button { - showingModal = true + editingItem = KeyValueItem(key: "", value: "") } label: { Image(systemName: "plus") } @@ -149,38 +173,64 @@ struct KeyValueTable: View { } label: { Image(systemName: "minus") } - .disabled(selection == nil) - .opacity(selection == nil ? 0.5 : 1) + .disabled(selection.isEmpty) + .opacity(selection.isEmpty ? 0.5 : 1) + + Spacer() + + actionBarTrailing() } - Spacer() } - .sheet(isPresented: $showingModal) { + .sheet(item: $editingItem) { item in NewListTableItemView( + key: item.key, + value: item.value, keyColumnName, valueColumnName, newItemInstruction, validKeys: validKeys, - headerView: AnyView(header()) + headerView: newItemHeader() ) { key, value in items[key] = value - updateTableItems() - showingModal = false + editingItem = nil } } .cornerRadius(6) - .onAppear(perform: updateTableItems) + .onAppear { + updateTableItems(items) + if let first = tableItems.first?.id { + selection = [first] + } + selection = [] + } + .onChange(of: items) { newValue in + updateTableItems(newValue) + } } - private func updateTableItems() { - tableItems = items.map { KeyValueItem(key: $0.key, value: $0.value) } + private func updateTableItems(_ newValue: [String: String]) { + tableItems = items + .sorted { $0.key < $1.key } + .map { KeyValueItem(key: $0.key, value: $0.value) } } private func removeItem() { - guard let selectedId = selection else { return } - if let selectedItem = tableItems.first(where: { $0.id == selectedId }) { - items.removeValue(forKey: selectedItem.key) - updateTableItems() + removeItem(selection) + self.selection.removeAll() + } + + private func removeItem(_ selection: Set) { + for selectedId in selection { + if let selectedItem = tableItems.first(where: { $0.id == selectedId }) { + items.removeValue(forKey: selectedItem.key) + } + } + } + + private func editItem(id: UUID?) { + guard let id, let item = tableItems.first(where: { $0.id == id }) else { + return } - selection = nil + editingItem = item } } diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index ef0175131..ae460323d 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -54,6 +54,10 @@ struct CodeFileView: View { var reformatAtColumn @AppSettings(\.textEditing.showReformattingGuide) var showReformattingGuide + @AppSettings(\.textEditing.invisibleCharacters) + var invisibleCharactersConfig + @AppSettings(\.textEditing.warningCharacters) + var warningCharacters @Environment(\.colorScheme) private var colorScheme @@ -141,7 +145,9 @@ struct CodeFileView: View { coordinators: textViewCoordinators, showMinimap: showMinimap, reformatAtColumn: reformatAtColumn, - showReformattingGuide: showReformattingGuide + showReformattingGuide: showReformattingGuide, + invisibleCharactersConfig: invisibleCharactersConfig.textViewOption(), + warningCharacters: warningCharacters.textViewOption() ) .id(codeFile.fileURL) .background { @@ -203,3 +209,31 @@ private extension SettingsData.TextEditingSettings.IndentOption { } } } + +private extension SettingsData.TextEditingSettings.InvisibleCharactersConfig { + func textViewOption() -> InvisibleCharactersConfig { + guard self.enabled else { + return .empty + } + + var config = InvisibleCharactersConfig( + showSpaces: self.showSpaces, + showTabs: self.showTabs, + showLineEndings: self.showLineEndings + ) + config.spaceReplacement = self.spaceReplacement + config.tabReplacement = self.tabReplacement + config.lineFeedReplacement = self.lineFeedReplacement + config.carriageReturnReplacement = self.carriageReturnReplacement + config.paragraphSeparatorReplacement = self.paragraphSeparatorReplacement + config.lineSeparatorReplacement = self.lineSeparatorReplacement + return config + } +} + +private extension SettingsData.TextEditingSettings.WarningCharacters { + func textViewOption() -> Set { + guard self.enabled else { return [] } + return Set(self.characters.keys) + } +} diff --git a/CodeEdit/Features/Settings/Pages/DeveloperSettings/DeveloperSettingsView.swift b/CodeEdit/Features/Settings/Pages/DeveloperSettings/DeveloperSettingsView.swift index eac495dae..0b1bcf0ba 100644 --- a/CodeEdit/Features/Settings/Pages/DeveloperSettings/DeveloperSettingsView.swift +++ b/CodeEdit/Features/Settings/Pages/DeveloperSettings/DeveloperSettingsView.swift @@ -34,7 +34,10 @@ struct DeveloperSettingsView: View { Text( "Specify the absolute path to your LSP binary and its associated language." ) + } actionBarTrailing: { + EmptyView() } + .frame(minHeight: 96) } header: { Text("LSP Binaries") Text("Specify the language and the absolute path to the language server binary.") diff --git a/CodeEdit/Features/Settings/Pages/TextEditingSettings/InvisiblesSettingsView.swift b/CodeEdit/Features/Settings/Pages/TextEditingSettings/InvisiblesSettingsView.swift new file mode 100644 index 000000000..d7c885d13 --- /dev/null +++ b/CodeEdit/Features/Settings/Pages/TextEditingSettings/InvisiblesSettingsView.swift @@ -0,0 +1,117 @@ +// +// InvisiblesSettingsView.swift +// CodeEdit +// +// Created by Khan Winter on 6/13/25. +// + +import SwiftUI + +struct InvisiblesSettingsView: View { + typealias Config = SettingsData.TextEditingSettings.InvisibleCharactersConfig + + @Binding var invisibleCharacters: Config + + @Environment(\.dismiss) + private var dismiss + + var body: some View { + VStack(spacing: 0) { + Form { + Section { + VStack { + Toggle(isOn: $invisibleCharacters.showSpaces) { Text("Show Spaces") } + if invisibleCharacters.showSpaces { + TextField( + text: $invisibleCharacters.spaceReplacement, + prompt: Text("Default: \(Config.default.spaceReplacement)") + ) { + Text("Character used to render spaces") + .foregroundStyle(.secondary) + .font(.caption) + } + .autocorrectionDisabled() + } + } + + VStack { + Toggle(isOn: $invisibleCharacters.showTabs) { Text("Show Tabs") } + if invisibleCharacters.showTabs { + TextField( + text: $invisibleCharacters.tabReplacement, + prompt: Text("Default: \(Config.default.tabReplacement)") + ) { + Text("Character used to render tabs") + .foregroundStyle(.secondary) + .font(.caption) + } + .autocorrectionDisabled() + } + } + + VStack { + Toggle(isOn: $invisibleCharacters.showLineEndings) { Text("Show Line Endings") } + if invisibleCharacters.showLineEndings { + TextField( + text: $invisibleCharacters.lineFeedReplacement, + prompt: Text("Default: \(Config.default.lineFeedReplacement)") + ) { + Text("Character used to render line feeds (\\n)") + .foregroundStyle(.secondary) + .font(.caption) + } + .autocorrectionDisabled() + + TextField( + text: $invisibleCharacters.carriageReturnReplacement, + prompt: Text("Default: \(Config.default.carriageReturnReplacement)") + ) { + Text("Character used to render carriage returns (Microsoft-style line endings)") + .foregroundStyle(.secondary) + .font(.caption) + } + .autocorrectionDisabled() + + TextField( + text: $invisibleCharacters.paragraphSeparatorReplacement, + prompt: Text("Default: \(Config.default.paragraphSeparatorReplacement)") + ) { + Text("Character used to render paragraph separators") + .foregroundStyle(.secondary) + .font(.caption) + } + .autocorrectionDisabled() + + TextField( + text: $invisibleCharacters.lineSeparatorReplacement, + prompt: Text("Default: \(Config.default.lineSeparatorReplacement)") + ) { + Text("Character used to render line separators") + .foregroundStyle(.secondary) + .font(.caption) + } + .autocorrectionDisabled() + } + } + } header: { + Text("Invisible Characters") + Text("Toggle whitespace symbols CodeEdit will render with replacement characters.") + } + .textFieldStyle(.roundedBorder) + } + .formStyle(.grouped) + Divider() + HStack { + Spacer() + Button { + dismiss() + } label: { + Text("Done") + .frame(minWidth: 56) + } + .buttonStyle(.borderedProminent) + } + .padding() + } + } +} diff --git a/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift b/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift index ba54a4725..f1db50b9e 100644 --- a/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift +++ b/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift @@ -31,6 +31,8 @@ extension SettingsData { "Show Minimap", "Reformat at Column", "Show Reformatting Guide", + "Invisibles", + "Warning Characters" ] if #available(macOS 14.0, *) { keys.append("System Cursor") @@ -82,13 +84,18 @@ extension SettingsData { /// Show the reformatting guide in the editor var showReformattingGuide: Bool = false + var invisibleCharacters: InvisibleCharactersConfig = .default + + /// Map of unicode character codes to a note about them + var warningCharacters: WarningCharacters = .default + /// Default initializer init() { self.populateCommands() } /// Explicit decoder init for setting default values when key is not present in `JSON` - init(from decoder: Decoder) throws { + init(from decoder: Decoder) throws { // swiftlint:disable:this function_body_length let container = try decoder.container(keyedBy: CodingKeys.self) self.defaultTabWidth = try container.decodeIfPresent(Int.self, forKey: .defaultTabWidth) ?? 4 self.indentOption = try container.decodeIfPresent( @@ -136,6 +143,14 @@ extension SettingsData { Bool.self, forKey: .showReformattingGuide ) ?? false + self.invisibleCharacters = try container.decodeIfPresent( + InvisibleCharactersConfig.self, + forKey: .invisibleCharacters + ) ?? .default + self.warningCharacters = try container.decodeIfPresent( + WarningCharacters.self, + forKey: .warningCharacters + ) ?? .default self.populateCommands() } @@ -222,6 +237,55 @@ extension SettingsData { } } } + + struct InvisibleCharactersConfig: Equatable, Hashable, Codable { + static var `default`: InvisibleCharactersConfig = { + InvisibleCharactersConfig( + enabled: false, + showSpaces: true, + showTabs: true, + showLineEndings: true + ) + }() + + var enabled: Bool + + var showSpaces: Bool + var showTabs: Bool + var showLineEndings: Bool + + var spaceReplacement: String = "·" + var tabReplacement: String = "→" + + // Controlled by `showLineEndings` + var carriageReturnReplacement: String = "↵" + var lineFeedReplacement: String = "¬" + var paragraphSeparatorReplacement: String = "¶" + var lineSeparatorReplacement: String = "⏎" + } + + struct WarningCharacters: Equatable, Hashable, Codable { + static let `default`: WarningCharacters = WarningCharacters(enabled: true, characters: [ + 0x0003: "End of text", + + 0x00A0: "Non-breaking space", + 0x202F: "Narrow non-breaking space", + 0x200B: "Zero-width space", + 0x200C: "Zero-width non-joiner", + 0x2029: "Paragraph separator", + + 0x2013: "Em-dash", + 0x00AD: "Soft hyphen", + + 0x2018: "Left single quote", + 0x2019: "Right single quote", + 0x201C: "Left double quote", + 0x201D: "Right double quote", + ]) + + var enabled: Bool + var characters: [UInt16: String] + } } struct EditorFont: Codable, Hashable { diff --git a/CodeEdit/Features/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift b/CodeEdit/Features/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift index d0b2bf22a..d3149431c 100644 --- a/CodeEdit/Features/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift +++ b/CodeEdit/Features/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift @@ -12,6 +12,9 @@ struct TextEditingSettingsView: View { @AppSettings(\.textEditing) var textEditing + @State private var isShowingInvisibleCharacterSettings = false + @State private var isShowingWarningCharactersSettings = false + var body: some View { SettingsForm { Section { @@ -37,6 +40,10 @@ struct TextEditingSettingsView: View { Section { bracketPairHighlight } + Section { + invisibles + warningCharacters + } } } } @@ -224,4 +231,50 @@ private extension TextEditingSettingsView { Toggle("Show Reformatting Guide", isOn: $textEditing.showReformattingGuide) .help("Shows a vertical guide at the reformat column") } + + @ViewBuilder private var invisibles: some View { + HStack { + Text("Show Invisible Characters") + Spacer() + Toggle(isOn: $textEditing.invisibleCharacters.enabled, label: { EmptyView() }) + Button { + isShowingInvisibleCharacterSettings = true + } label: { + Text("Configure...") + } + .disabled(textEditing.invisibleCharacters.enabled == false) + } + .contentShape(Rectangle()) + .onTapGesture { + if textEditing.invisibleCharacters.enabled { + isShowingInvisibleCharacterSettings = true + } + } + .sheet(isPresented: $isShowingInvisibleCharacterSettings) { + InvisiblesSettingsView(invisibleCharacters: $textEditing.invisibleCharacters) + } + } + + @ViewBuilder private var warningCharacters: some View { + HStack { + Text("Show Warning Characters") + Spacer() + Toggle(isOn: $textEditing.warningCharacters.enabled, label: { EmptyView() }) + Button { + isShowingWarningCharactersSettings = true + } label: { + Text("Configure...") + } + .disabled(textEditing.warningCharacters.enabled == false) + } + .contentShape(Rectangle()) + .onTapGesture { + if textEditing.warningCharacters.enabled { + isShowingWarningCharactersSettings = true + } + } + .sheet(isPresented: $isShowingWarningCharactersSettings) { + WarningCharactersView(warningCharacters: $textEditing.warningCharacters) + } + } } diff --git a/CodeEdit/Features/Settings/Views/InvisibleCharacterWarningList.swift b/CodeEdit/Features/Settings/Views/InvisibleCharacterWarningList.swift new file mode 100644 index 000000000..cf7bd58f2 --- /dev/null +++ b/CodeEdit/Features/Settings/Views/InvisibleCharacterWarningList.swift @@ -0,0 +1,66 @@ +// +// InvisibleCharacterWarningList.swift +// CodeEdit +// +// Created by Khan Winter on 6/13/25. +// + +import SwiftUI + +struct InvisibleCharacterWarningList: View { + @Binding var items: [UInt16: String] + + @State private var selection: String? + + var body: some View { + KeyValueTable( + items: Binding( + get: { + items.reduce(into: [String: String]()) { dict, keyVal in + let hex = String(keyVal.key, radix: 16).uppercased() + let padding = String(repeating: "0", count: 4 - hex.count) + dict["U+" + padding + hex] = keyVal.value + } + }, + set: { dict in + items = dict.reduce(into: [UInt16: String]()) { dict, keyVal in + guard let intFromHex = UInt(hexString: String(keyVal.key.trimmingPrefix("U+"))), + intFromHex < UInt16.max else { + return + } + let charCode = UInt16(intFromHex) + dict[charCode] = keyVal.value + } + } + ), + keyColumnName: "Unicode Character Code", + valueColumnName: "Notes", + newItemInstruction: "Add A Character As A Hexidecimal Unicode Value", + actionBarTrailing: { + Button { + // Add defaults without removing user's data. We do still override notes here. + items = items.merging( + SettingsData.TextEditingSettings.WarningCharacters.default.characters, + uniquingKeysWith: { _, defaults in + defaults + } + ) + } label: { + Text("Restore Defaults") + } + .buttonStyle(PlainButtonStyle()) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: true, vertical: false) + .padding(.trailing, 4) + } + ) + .frame(minHeight: 96, maxHeight: .infinity) + .overlay { + if items.isEmpty { + Text("No warning characters") + .foregroundStyle(Color(.secondaryLabelColor)) + } + } + } +} diff --git a/CodeEdit/Features/Settings/Views/WarningCharactersView.swift b/CodeEdit/Features/Settings/Views/WarningCharactersView.swift new file mode 100644 index 000000000..bc2c21133 --- /dev/null +++ b/CodeEdit/Features/Settings/Views/WarningCharactersView.swift @@ -0,0 +1,47 @@ +// +// WarningCharactersView.swift +// CodeEdit +// +// Created by Khan Winter on 6/16/25. +// + +import SwiftUI + +struct WarningCharactersView: View { + typealias Config = SettingsData.TextEditingSettings.WarningCharacters + + @Binding var warningCharacters: Config + + @Environment(\.dismiss) + private var dismiss + + var body: some View { + VStack(spacing: 0) { + Form { + Section { + InvisibleCharacterWarningList(items: $warningCharacters.characters) + } header: { + Text("Warning Characters") + Text( + "CodeEdit can help identify invisible or ambiguous characters, such as zero-width spaces," + + " directional quotes, and more. These will appear with a red block highlighting them." + + " You can disable characters or add more here." + ) + } + } + .formStyle(.grouped) + Divider() + HStack { + Spacer() + Button { + dismiss() + } label: { + Text("Done") + .frame(minWidth: 56) + } + .buttonStyle(.borderedProminent) + } + .padding() + } + } +} diff --git a/CodeEdit/Utils/Extensions/Int/Int+HexString.swift b/CodeEdit/Utils/Extensions/Int/Int+HexString.swift new file mode 100644 index 000000000..568322795 --- /dev/null +++ b/CodeEdit/Utils/Extensions/Int/Int+HexString.swift @@ -0,0 +1,28 @@ +// +// Int+HexString.swift +// CodeEdit +// +// Created by Khan Winter on 6/13/25. +// + +extension UInt { + init?(hexString: String) { + // Trim 0x if it's there + let string = String(hexString.trimmingPrefix("0x")) + guard let value = UInt(string, radix: 16) else { + return nil + } + self = value + } +} + +extension Int { + init?(hexString: String) { + // Trim 0x if it's there + let string = String(hexString.trimmingPrefix("0x")) + guard let value = Int(string, radix: 16) else { + return nil + } + self = value + } +}