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 4d8a5e67c..e301b688b 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -57,6 +57,10 @@ struct CodeFileView: View { var reformatAtColumn @AppSettings(\.textEditing.showReformattingGuide) var showReformattingGuide + @AppSettings(\.textEditing.invisibleCharacters) + var invisibleCharactersConfiguration + @AppSettings(\.textEditing.warningCharacters) + var warningCharacters @Environment(\.colorScheme) private var colorScheme @@ -139,8 +143,8 @@ struct CodeFileView: View { showMinimap: showMinimap, showReformattingGuide: showReformattingGuide, showFoldingRibbon: showFoldingRibbon, - invisibleCharactersConfiguration: .empty, - warningCharacters: [] + invisibleCharactersConfiguration: invisibleCharactersConfiguration.textViewOption(), + warningCharacters: Set(warningCharacters.characters.keys) ) ), state: $editorState, @@ -208,3 +212,23 @@ private extension SettingsData.TextEditingSettings.IndentOption { } } } + +private extension SettingsData.TextEditingSettings.InvisibleCharactersConfig { + func textViewOption() -> InvisibleCharactersConfiguration { + guard self.enabled else { return .empty } + var config = InvisibleCharactersConfiguration( + showSpaces: self.showSpaces, + showTabs: self.showTabs, + showLineEndings: self.showLineEndings + ) + + config.spaceReplacement = self.spaceReplacement + config.tabReplacement = self.tabReplacement + config.carriageReturnReplacement = self.carriageReturnReplacement + config.lineFeedReplacement = self.lineFeedReplacement + config.paragraphSeparatorReplacement = self.paragraphSeparatorReplacement + config.lineSeparatorReplacement = self.lineSeparatorReplacement + + return config + } +} 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 decc93cae..b5b5abb5a 100644 --- a/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift +++ b/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift @@ -32,6 +32,8 @@ extension SettingsData { "Show Minimap", "Reformat at Column", "Show Reformatting Guide", + "Invisibles", + "Warning Characters" ] if #available(macOS 14.0, *) { keys.append("System Cursor") @@ -89,13 +91,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( @@ -145,6 +152,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() } @@ -239,6 +254,57 @@ 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", + + 0x037E: "Greek Question Mark" + ]) + + 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 6257cdef5..73d9eca77 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 { @@ -41,6 +44,10 @@ struct TextEditingSettingsView: View { Section { bracketPairHighlight } + Section { + invisibles + warningCharacters + } } } } @@ -240,4 +247,50 @@ private extension TextEditingSettingsView { ) .help("The column at which text should be reformatted.") } + + @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 + } +}