Skip to content

Implement Invisible Characters Setting #2065

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jul 10, 2025
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 77 additions & 27 deletions CodeEdit/Features/CodeEditUI/Views/KeyValueTable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ struct KeyValueItem: Identifiable, Equatable {
let value: String
}

private struct NewListTableItemView: View {
private struct NewListTableItemView<HeaderView: View>: View {
@Environment(\.dismiss)
var dismiss

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -94,17 +102,18 @@ private struct NewListTableItemView: View {
}
}

struct KeyValueTable<Header: View>: View {
struct KeyValueTable<Header: View, ActionBarView: View>: 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<UUID> = []
@State private var tableItems: [KeyValueItem] = []

init(
Expand All @@ -113,14 +122,16 @@ struct KeyValueTable<Header: View>: 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 {
Expand All @@ -132,11 +143,24 @@ struct KeyValueTable<Header: View>: 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")
}
Expand All @@ -149,38 +173,64 @@ struct KeyValueTable<Header: View>: 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<UUID>) {
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
}
}
28 changes: 26 additions & 2 deletions CodeEdit/Features/Editor/Views/CodeFileView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
Loading