diff --git a/apple/Packages/TableProEditor/Sources/TableProTextEngine/SpecialCharacters/SpecialCharacter.swift b/apple/Packages/TableProEditor/Sources/TableProTextEngine/SpecialCharacters/SpecialCharacter.swift index 09ab4be410..4428981c01 100644 --- a/apple/Packages/TableProEditor/Sources/TableProTextEngine/SpecialCharacters/SpecialCharacter.swift +++ b/apple/Packages/TableProEditor/Sources/TableProTextEngine/SpecialCharacters/SpecialCharacter.swift @@ -25,9 +25,22 @@ public extension ClassifiedSpecialCharacter { } public extension SpecialCharacter { - static func classify(in string: NSString, at index: Int) -> ClassifiedSpecialCharacter? { + // `marksLineBreaks` is for a view showing a value on one line, which draws no break for CR or LF. + static func classify( + in string: NSString, + at index: Int, + marksLineBreaks: Bool = false + ) -> ClassifiedSpecialCharacter? { guard index >= 0, index < string.length else { return nil } - guard mayBeSpecial(string.character(at: index)) else { return nil } + let unit = string.character(at: index) + if marksLineBreaks, lineBreakControls.contains(UInt32(unit)), let scalar = Unicode.Scalar(unit) { + return ClassifiedSpecialCharacter( + character: .marker(label: controlLabel(for: scalar.value)), + range: NSRange(location: index, length: 1), + scalar: scalar + ) + } + guard mayBeSpecial(unit) else { return nil } guard let scalar = scalar(in: string, at: index) else { return nil } let range = NSRange(location: index, length: scalar.utf16.count) guard let character = classify(scalar, in: string, range: range) else { return nil } @@ -59,6 +72,8 @@ public extension SpecialCharacter { private extension SpecialCharacter { static let preservedControls: Set = [0x09, 0x0A, 0x0D] + static let lineBreakControls: Set = [0x0A, 0x0D] + static let combiningIgnorables: Set = [ 0x034F, 0x115F, 0x1160, 0x17B4, 0x17B5, 0x180B, 0x180C, 0x180D, 0x180F ] diff --git a/apple/Packages/TableProEditor/Sources/TableProTextEngine/SpecialCharacters/SpecialCharacterDisplay.swift b/apple/Packages/TableProEditor/Sources/TableProTextEngine/SpecialCharacters/SpecialCharacterDisplay.swift index 4629c2408c..59c45c9817 100644 --- a/apple/Packages/TableProEditor/Sources/TableProTextEngine/SpecialCharacters/SpecialCharacterDisplay.swift +++ b/apple/Packages/TableProEditor/Sources/TableProTextEngine/SpecialCharacters/SpecialCharacterDisplay.swift @@ -46,7 +46,11 @@ private extension SpecialCharacterDisplay { var index = 0 while index < text.length { guard marks.count < style.maximumMarksPerLine else { return index } - guard let classified = SpecialCharacter.classify(in: text, at: index) else { + guard let classified = SpecialCharacter.classify( + in: text, + at: index, + marksLineBreaks: style.marksLineBreaks + ) else { index += 1 continue } diff --git a/apple/Packages/TableProEditor/Sources/TableProTextEngine/SpecialCharacters/SpecialCharacterLine.swift b/apple/Packages/TableProEditor/Sources/TableProTextEngine/SpecialCharacters/SpecialCharacterLine.swift new file mode 100644 index 0000000000..bac3501d4a --- /dev/null +++ b/apple/Packages/TableProEditor/Sources/TableProTextEngine/SpecialCharacters/SpecialCharacterLine.swift @@ -0,0 +1,93 @@ +// +// SpecialCharacterLine.swift +// TableProTextEngine +// + +import AppKit + +// The editor's marks for a view that draws one line itself, such as a grid cell. +public struct SpecialCharacterLine { + public let line: CTLine + public let marks: [SpecialCharacterMark] + + public init(_ string: NSAttributedString, style: SpecialCharacterStyle) { + let display = SpecialCharacterDisplay.make(from: string, style: style) + let line = CTLineCreateWithAttributedString(display.string) + self.line = line + marks = SpecialCharacterGeometry.positioned(display.marks, in: line) + } + + // `drawn` is this line or an end truncation of it. A right-to-left line moves what it keeps, so + // the marks are placed again, and the ones the ellipsis replaced are dropped. + public func marks(drawnIn drawn: CTLine) -> [SpecialCharacterMark] { + guard drawn !== line, !marks.isEmpty else { return marks } + let length = CTLineGetStringRange(line).length + let runs = CTLineGetGlyphRuns(drawn) as? [CTRun] ?? [] + let cut = runs.map(CTRunGetStringRange) + .filter { $0.location + $0.length == length } + .map(\.location) + .min() ?? length + let kept = marks.filter { $0.offset + $0.length <= cut } + return SpecialCharacterGeometry.positioned(kept, in: drawn) + } + + public static func draw( + _ marks: [SpecialCharacterMark], + fromX x: CGFloat, + top: CGFloat, + height: CGFloat, + in context: CGContext + ) { + for mark in marks { + SpecialCharacterMarkRenderer.draw( + mark, + from: x + mark.minX, + to: x + mark.maxX, + top: top, + height: height, + color: mark.color, + in: context + ) + } + } +} + +// One mark as an inline attachment, for a text view that swaps it in for its character. +public struct SpecialCharacterMarkImage { + public let image: NSImage + public let bounds: CGRect + + public init(_ classified: ClassifiedSpecialCharacter, in text: NSString, font: NSFont, color: NSColor) { + let width: CGFloat + switch classified.character { + case .marker(let label): + width = SpecialCharacterMetrics.markerWidth(label: label, font: font) + case .blankSpace: + let space = NSAttributedString(string: text.substring(with: classified.range), attributes: [.font: font]) + width = CGFloat(CTLineGetTypographicBounds(CTLineCreateWithAttributedString(space), nil, nil, nil)) + } + let height = font.ascender - font.descender + bounds = CGRect(x: 0, y: font.descender, width: width, height: height) + + let mark = SpecialCharacterMark( + offset: 0, + length: classified.range.length, + character: classified.character, + font: font, + color: color + ) + image = NSImage(size: NSSize(width: width, height: height), flipped: true) { rect in + guard let context = NSGraphicsContext.current?.cgContext else { return false } + SpecialCharacterMarkRenderer.draw( + mark, + from: rect.minX, + to: rect.maxX, + top: rect.minY, + height: rect.height, + color: color, + in: context + ) + return true + } + } +} diff --git a/apple/Packages/TableProEditor/Sources/TableProTextEngine/SpecialCharacters/SpecialCharacterStyle.swift b/apple/Packages/TableProEditor/Sources/TableProTextEngine/SpecialCharacters/SpecialCharacterStyle.swift index 3118f0f669..dc75fc7813 100644 --- a/apple/Packages/TableProEditor/Sources/TableProTextEngine/SpecialCharacters/SpecialCharacterStyle.swift +++ b/apple/Packages/TableProEditor/Sources/TableProTextEngine/SpecialCharacters/SpecialCharacterStyle.swift @@ -8,10 +8,12 @@ import AppKit public struct SpecialCharacterStyle: Equatable { public var color: NSColor public var maximumMarksPerLine: Int + public var marksLineBreaks: Bool - public init(color: NSColor = .systemOrange, maximumMarksPerLine: Int = 4_096) { + public init(color: NSColor = .systemOrange, maximumMarksPerLine: Int = 4_096, marksLineBreaks: Bool = false) { self.color = color self.maximumMarksPerLine = maximumMarksPerLine + self.marksLineBreaks = marksLineBreaks } } diff --git a/apple/Packages/TableProEditor/Tests/TableProTextEngineTests/SpecialCharacterLineTests.swift b/apple/Packages/TableProEditor/Tests/TableProTextEngineTests/SpecialCharacterLineTests.swift new file mode 100644 index 0000000000..9655587f4a --- /dev/null +++ b/apple/Packages/TableProEditor/Tests/TableProTextEngineTests/SpecialCharacterLineTests.swift @@ -0,0 +1,133 @@ +import AppKit +@testable import TableProTextEngine +import Testing + +@MainActor +struct SpecialCharacterLineTests { + private let font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) + + private func attributed(_ text: String) -> NSAttributedString { + NSAttributedString(string: text, attributes: [.font: font, .foregroundColor: NSColor.black]) + } + + private func width(of line: CTLine) -> CGFloat { + CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil)) + } + + @Test("A zero-width space takes the room of its marker, right after the text before it") + func markerReservesItsWidth() throws { + let marked = SpecialCharacterLine(attributed("abc\u{200B}"), style: SpecialCharacterStyle()) + let plain = CTLineCreateWithAttributedString(attributed("abc")) + let mark = try #require(marked.marks.first) + + #expect(marked.marks.count == 1) + #expect(mark.character == .marker(label: "ZWSP")) + #expect(abs(mark.minX - width(of: plain)) < 0.5) + #expect(mark.maxX - mark.minX > 10) + #expect(abs(width(of: marked.line) - mark.maxX) < 0.5) + } + + @Test("CR and LF are marked only on a single-line surface") + func lineBreaksMarkedOnRequest() { + let text = "a\rb\nc" as NSString + + #expect(SpecialCharacter.classify(in: text, at: 1) == nil) + #expect(SpecialCharacter.classify(in: text, at: 3) == nil) + #expect(SpecialCharacter.classify(in: text, at: 1, marksLineBreaks: true)?.character == .marker(label: "CR")) + #expect(SpecialCharacter.classify(in: text, at: 3, marksLineBreaks: true)?.character == .marker(label: "LF")) + #expect(SpecialCharacter.classify(in: "\t" as NSString, at: 0, marksLineBreaks: true) == nil) + + let line = SpecialCharacterLine(attributed("abc\r"), style: SpecialCharacterStyle(marksLineBreaks: true)) + #expect(line.marks.map { $0.character } == [.marker(label: "CR")]) + } + + @Test("A right-to-left override no longer reorders the text after it") + func bidiOverrideIsNeutralised() throws { + let line = SpecialCharacterLine(attributed("a\u{202E}bc"), style: SpecialCharacterStyle()) + var offsets: [Int: CGFloat] = [:] + CTLineEnumerateCaretOffsets(line.line) { offset, index, leading, _ in + if leading, offsets[index] == nil { offsets[index] = offset } + } + + let b = try #require(offsets[2]) + let c = try #require(offsets[3]) + #expect(b < c) + } + + @Test("A truncated line keeps the marks of what it shows and drops the ones the ellipsis replaced") + func truncationDropsHiddenMarks() throws { + let tail = SpecialCharacterLine(attributed(String(repeating: "x", count: 40) + "\u{200B}"), style: SpecialCharacterStyle()) + let head = SpecialCharacterLine(attributed("\u{200B}" + String(repeating: "x", count: 40)), style: SpecialCharacterStyle()) + let ellipsis = CTLineCreateWithAttributedString(attributed("\u{2026}")) + let tailCut = try #require(CTLineCreateTruncatedLine(tail.line, 120, .end, ellipsis)) + let headCut = try #require(CTLineCreateTruncatedLine(head.line, 120, .end, ellipsis)) + + #expect(tail.marks(drawnIn: tailCut).isEmpty) + #expect(head.marks(drawnIn: headCut) == head.marks) + #expect(head.marks(drawnIn: head.line) == head.marks) + } + + @Test("A truncated right-to-left line places its marks where the kept text moved to") + func truncatedRightToLeftMarksMove() throws { + let text = "\u{05E9}\u{05DC}\u{05D5}\u{05DD}\u{00A0}" + String(repeating: "\u{05E2}\u{05D5}\u{05DC}\u{05DD} ", count: 8) + let marked = SpecialCharacterLine(attributed(text), style: SpecialCharacterStyle()) + let ellipsis = CTLineCreateWithAttributedString(attributed("\u{2026}")) + let cut = try #require(CTLineCreateTruncatedLine(marked.line, 120, .end, ellipsis)) + let full = try #require(marked.marks.first) + let shown = try #require(marked.marks(drawnIn: cut).first) + var caret: CGFloat? + CTLineEnumerateCaretOffsets(cut) { offset, index, _, _ in + if index == 4 { caret = min(caret ?? offset, offset) } + } + + #expect(abs(shown.minX - full.minX) > 50) + #expect(abs(shown.minX - (caret ?? -1)) < 0.5) + #expect(shown.maxX <= 120) + } + + @Test("Drawing a line's marks inks its boxes") + func marksAreDrawn() { + let line = SpecialCharacterLine(attributed("\u{200B}"), style: SpecialCharacterStyle(color: .black)) + + #expect(inkedPixels(line.marks) > 0) + #expect(inkedPixels([]) == 0) + } + + @Test("A marker image is as wide as the marker in a line, a special space as the space itself") + func markImageWidths() throws { + let text = "\u{200B}\u{00A0}" as NSString + let marker = try #require(SpecialCharacter.classify(in: text, at: 0)) + let space = try #require(SpecialCharacter.classify(in: text, at: 1)) + let line = SpecialCharacterLine(attributed("\u{200B}"), style: SpecialCharacterStyle()) + let lineMark = try #require(line.marks.first) + let spaceWidth = width(of: CTLineCreateWithAttributedString(attributed("\u{00A0}"))) + + let markerImage = SpecialCharacterMarkImage(marker, in: text, font: font, color: .black) + let spaceImage = SpecialCharacterMarkImage(space, in: text, font: font, color: .black) + + #expect(abs(markerImage.bounds.width - (lineMark.maxX - lineMark.minX)) < 0.5) + #expect(abs(spaceImage.bounds.width - spaceWidth) < 0.5) + #expect(markerImage.bounds.minY == font.descender) + #expect(markerImage.image.size == markerImage.bounds.size) + } + + private func inkedPixels(_ marks: [SpecialCharacterMark]) -> Int { + let width = 80 + let height = 24 + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return 0 } + context.translateBy(x: 0, y: CGFloat(height)) + context.scaleBy(x: 1, y: -1) + SpecialCharacterLine.draw(marks, fromX: 0, top: 2, height: 18, in: context) + guard let data = context.data else { return 0 } + let bytes = data.bindMemory(to: UInt8.self, capacity: width * height * 4) + return (0 ..< width * height).filter { bytes[$0 * 4 + 3] > 0 }.count + } +} diff --git a/apple/TablePro/Core/Services/Formatting/CellDisplayFormatter.swift b/apple/TablePro/Core/Services/Formatting/CellDisplayFormatter.swift index 8507573bcb..7c3c764490 100644 --- a/apple/TablePro/Core/Services/Formatting/CellDisplayFormatter.swift +++ b/apple/TablePro/Core/Services/Formatting/CellDisplayFormatter.swift @@ -56,13 +56,13 @@ enum CellDisplayFormatter { /// A row is one line, so anything a format produces is capped and stripped of line breaks /// before it reaches a cell. Binary read as text is the case that needs it: a blob carries - /// newlines and runs to megabytes where hex never did. + /// newlines and runs to megabytes where hex never did. A lone CR stays for the cell to mark. private static func fitToCell(_ value: String) -> String { var displayValue = value let nsDisplay = displayValue as NSString if nsDisplay.length > maxDisplayLength { displayValue = nsDisplay.substring(to: maxDisplayLength) + "…" } - return displayValue.sanitizedForCellDisplay + return displayValue.flattenedForCell } } diff --git a/apple/TablePro/Core/Utilities/Text/RevealedText.swift b/apple/TablePro/Core/Utilities/Text/RevealedText.swift index b33f0f8795..ed02486e57 100644 --- a/apple/TablePro/Core/Utilities/Text/RevealedText.swift +++ b/apple/TablePro/Core/Utilities/Text/RevealedText.swift @@ -24,8 +24,8 @@ internal struct RevealedText: Equatable, Sendable { internal let segments: [Segment] - internal init(_ text: String) { - segments = Self.segments(of: text as NSString) + internal init(_ text: String, marksLineBreaks: Bool = false) { + segments = Self.segments(of: text as NSString, marksLineBreaks: marksLineBreaks) } internal var revealsAnyCharacter: Bool { @@ -53,12 +53,12 @@ internal struct RevealedText: Equatable, Sendable { } private extension RevealedText { - static func segments(of text: NSString) -> [Segment] { + static func segments(of text: NSString, marksLineBreaks: Bool) -> [Segment] { var segments: [Segment] = [] var runStart = 0 var index = 0 while index < text.length { - guard let classified = SpecialCharacter.classify(in: text, at: index) else { + guard let classified = SpecialCharacter.classify(in: text, at: index, marksLineBreaks: marksLineBreaks) else { index += 1 continue } diff --git a/apple/TablePro/Models/Query/ColumnValueFilterSelection.swift b/apple/TablePro/Models/Query/ColumnValueFilterSelection.swift index b6bc52c220..ecaa5e0bda 100644 --- a/apple/TablePro/Models/Query/ColumnValueFilterSelection.swift +++ b/apple/TablePro/Models/Query/ColumnValueFilterSelection.swift @@ -99,7 +99,7 @@ internal extension ColumnDistinctValue { /// What the popover lists and what its search matches, so "null" finds the NULL entry. var label: String { if isNull { return String(localized: "(NULL)") } - return display.isEmpty ? String(localized: "(Empty)") : display + return display.isEmpty ? String(localized: "(Empty)") : display.shownWithoutMarks } } diff --git a/apple/TablePro/Models/Settings/AppSettings.swift b/apple/TablePro/Models/Settings/AppSettings.swift index 17a1259b13..a18e4aeb1b 100644 --- a/apple/TablePro/Models/Settings/AppSettings.swift +++ b/apple/TablePro/Models/Settings/AppSettings.swift @@ -125,6 +125,7 @@ struct DataGridSettings: Codable, Equatable { var defaultSortBehavior: DefaultSortBehavior var defaultSortDirection: SortDirection var rowCopyFormat: RowCopyFormat + var showInvisibleCharacters: Bool static let `default` = DataGridSettings( rowHeight: .normal, @@ -140,7 +141,8 @@ struct DataGridSettings: Codable, Equatable { truncateQueryResults: true, defaultSortBehavior: .none, defaultSortDirection: .ascending, - rowCopyFormat: .rows + rowCopyFormat: .rows, + showInvisibleCharacters: true ) init( @@ -157,7 +159,8 @@ struct DataGridSettings: Codable, Equatable { truncateQueryResults: Bool = true, defaultSortBehavior: DefaultSortBehavior = .none, defaultSortDirection: SortDirection = .ascending, - rowCopyFormat: RowCopyFormat = .rows + rowCopyFormat: RowCopyFormat = .rows, + showInvisibleCharacters: Bool = true ) { self.rowHeight = rowHeight self.dateFormat = dateFormat @@ -173,6 +176,7 @@ struct DataGridSettings: Codable, Equatable { self.defaultSortBehavior = defaultSortBehavior self.defaultSortDirection = defaultSortDirection self.rowCopyFormat = rowCopyFormat + self.showInvisibleCharacters = showInvisibleCharacters } init(from decoder: Decoder) throws { @@ -197,6 +201,9 @@ struct DataGridSettings: Codable, Equatable { defaultSortBehavior = container.decodeSetting(.defaultSortBehavior, default: fallback.defaultSortBehavior) defaultSortDirection = container.decodeSetting(.defaultSortDirection, default: fallback.defaultSortDirection) rowCopyFormat = container.decodeSetting(.rowCopyFormat, default: fallback.rowCopyFormat) + showInvisibleCharacters = container.decodeSetting( + .showInvisibleCharacters, default: fallback.showInvisibleCharacters + ) } // MARK: - Validated Properties diff --git a/apple/TablePro/Models/UI/MultiRowEditState.swift b/apple/TablePro/Models/UI/MultiRowEditState.swift index 61b2267bb1..c3441b1725 100644 --- a/apple/TablePro/Models/UI/MultiRowEditState.swift +++ b/apple/TablePro/Models/UI/MultiRowEditState.swift @@ -163,6 +163,16 @@ final class MultiRowEditState: ObservableObject { private(set) var cellWriter: CellWriterSupport = .none private(set) var rowsAreInserted = false + private var revealsInvisibleCharacters: Bool + private var settingsCancellable: AnyCancellable? + + init() { + revealsInvisibleCharacters = AppSettingsManager.shared.dataGrid.showInvisibleCharacters + settingsCancellable = AppEvents.shared.dataGridSettingsChanged + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.adoptInvisibleCharacterSetting() } + } + var hasEdits: Bool { fields.contains { $0.hasEdit } } @@ -309,7 +319,8 @@ final class MultiRowEditState: ObservableObject { isLongText: isLongText, originalValue: originalValue, displayFormatOverride: displayFormat, - isBinaryValue: newField.isBinaryValue + isBinaryValue: newField.isBinaryValue, + revealsInvisibleCharacters: revealsInvisibleCharacters ) newFields.append(newField) } @@ -342,6 +353,28 @@ final class MultiRowEditState: ObservableObject { return Set(holdsBytes.indices.filter { holdsBytes[$0] && !holdsText[$0] }) } + // A value holding an invisible character takes the text view only while marks are on, so a field + // shown when the setting changes is resolved again. + private func adoptInvisibleCharacterSetting() { + let reveals = AppSettingsManager.shared.dataGrid.showInvisibleCharacters + guard reveals != revealsInvisibleCharacters else { return } + revealsInvisibleCharacters = reveals + for index in fields.indices where !fields[index].isSchemaField && fields[index].editor == nil { + let field = fields[index] + let resolved = FieldEditorResolver.resolve( + for: field.columnTypeEnum, + isLongText: field.isLongText, + originalValue: field.originalValue, + displayFormatOverride: field.displayFormat, + isBinaryValue: field.isBinaryValue, + revealsInvisibleCharacters: reveals + ) + if resolved != field.resolvedEditor { + fields[index].resolvedEditor = resolved + } + } + } + /// Configure state for a single schema row supplied by the grid that owns the selection. /// Field ids survive a reconfigure of the same row so a commit does not rebuild the /// editors and drop focus while the user is still moving between fields. diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index a24f0918ed..47f27036e2 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -2587,6 +2587,9 @@ } } } + }, + "%@ bytes" : { + }, "%@ can only be compared with a whole number." : { "extractionState" : "manual", @@ -3011,6 +3014,9 @@ } } } + }, + "%@ characters" : { + }, "%@ completed" : { "localizations" : { @@ -12781,6 +12787,9 @@ } } } + }, + "1 byte" : { + }, "1 cell" : { "localizations" : { @@ -12791,6 +12800,9 @@ } } } + }, + "1 character" : { + }, "1 connection" : { "localizations" : { @@ -35455,6 +35467,9 @@ } } } + }, + "Characters are Unicode code points. Bytes are the length in UTF-8." : { + }, "Charset" : { "extractionState" : "manual", @@ -87779,6 +87794,9 @@ } } } + }, + "Holds a bidirectional control, which can change the order the text is shown in" : { + }, "Holds a command that forwards a local port, such as kubectl port-forward." : { "localizations" : { @@ -104819,6 +104837,9 @@ "Map Pin" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Mark zero-width, control and special space characters in cells and value fields" : { + }, "Markdown" : { "localizations" : { diff --git a/apple/TablePro/Views/Results/CellOverlayBase.swift b/apple/TablePro/Views/Results/CellOverlayBase.swift index 9bc038ec2e..7e1c4b8685 100644 --- a/apple/TablePro/Views/Results/CellOverlayBase.swift +++ b/apple/TablePro/Views/Results/CellOverlayBase.swift @@ -28,6 +28,7 @@ class CellOverlayBase: NSObject { /// Held for the life of the open overlay: its subscription is what repaints the text on a theme /// change. private var textStyle: ThemeTextViewStyle? + private var invisibleCharacters: InvisibleCharacterTextDisplay? var onRemove: (() -> Void)? private(set) var row: Int = -1 @@ -90,6 +91,7 @@ class CellOverlayBase: NSObject { activeContainer.removeFromSuperview() container = nil textStyle = nil + invisibleCharacters = nil if let hostTableView { hostTableView.window?.makeFirstResponder(hostTableView) } @@ -98,9 +100,14 @@ class CellOverlayBase: NSObject { static func overlayFrame(for cellFrame: NSRect, value: String) -> NSRect { let lineHeight = ThemeEngine.shared.dataGridFonts.regular.boundingRectForFont.height + 4 + // A CR on its own breaks the line in the text view as LF does; CRLF is one break. var newlineCount = 0 - for scalar in value.unicodeScalars where scalar == "\n" { - newlineCount += 1 + var previous: Unicode.Scalar? + for scalar in value.unicodeScalars { + if scalar == "\r" || (scalar == "\n" && previous != "\r") { + newlineCount += 1 + } + previous = scalar } let lineCount = CGFloat(newlineCount + 1) let contentHeight = max(lineCount * lineHeight + 8, cellFrame.height) @@ -130,6 +137,14 @@ class CellOverlayBase: NSObject { textStyle = ThemeTextViewStyle(textView: textView, scope: scope) } + // Before the text is set, so the value is laid out once. + func markInvisibleCharacters(in textView: NSTextView) { + invisibleCharacters = InvisibleCharacterTextDisplay.install( + on: textView, + isEnabled: AppSettingsManager.shared.dataGrid.showInvisibleCharacters + ) + } + /// Lays a text view out the way an inline cell overlay needs. /// /// A cell holds one value, so the overlay behaves like a field editor and scrolls a long line diff --git a/apple/TablePro/Views/Results/CellOverlayEditor.swift b/apple/TablePro/Views/Results/CellOverlayEditor.swift index 25b72b5281..0e59bdf9f5 100644 --- a/apple/TablePro/Views/Results/CellOverlayEditor.swift +++ b/apple/TablePro/Views/Results/CellOverlayEditor.swift @@ -42,6 +42,7 @@ final class CellOverlayEditor: CellOverlayBase, NSTextViewDelegate { applyTheme(to: textView, scope: scope) textView.focusRingType = .none Self.applyCellTextLayout(to: textView) + markInvisibleCharacters(in: textView) textView.delegate = self textView.string = value textView.selectAll(nil) diff --git a/apple/TablePro/Views/Results/CellOverlayViewer.swift b/apple/TablePro/Views/Results/CellOverlayViewer.swift index 14f80993a8..9b6e2c5bbd 100644 --- a/apple/TablePro/Views/Results/CellOverlayViewer.swift +++ b/apple/TablePro/Views/Results/CellOverlayViewer.swift @@ -34,6 +34,7 @@ final class CellOverlayViewer: CellOverlayBase, NSTextViewDelegate { textView.backgroundColor = .textBackgroundColor applyTheme(to: textView, scope: scope) Self.applyCellTextLayout(to: textView) + markInvisibleCharacters(in: textView) textView.delegate = self textView.string = value textView.selectAll(nil) diff --git a/apple/TablePro/Views/Results/Cells/DataGridCellAppearance.swift b/apple/TablePro/Views/Results/Cells/DataGridCellAppearance.swift index 42ae8e2b18..5219f8a58c 100644 --- a/apple/TablePro/Views/Results/Cells/DataGridCellAppearance.swift +++ b/apple/TablePro/Views/Results/Cells/DataGridCellAppearance.swift @@ -31,6 +31,8 @@ struct DataGridCellAppearance: Equatable { let drawsFocusRing: Bool let focusColor: NSColor let checkboxMark: DataGridCheckboxMark? + /// Nil leaves invisible characters unmarked. + let specialCharacterColor: NSColor? static func resolve( kind: DataGridCellKind, @@ -41,7 +43,8 @@ struct DataGridCellAppearance: Equatable { onEmphasizedSelection: Bool, hasOverlay: Bool, checkboxMark: DataGridCheckboxMark? = nil, - textTone: NSColor? = nil + textTone: NSColor? = nil, + revealsInvisibleCharacters: Bool = false ) -> DataGridCellAppearance { let deletedTextColor = state.visualState.isDeleted ? palette.deletedRowText : nil let font: NSFont @@ -93,6 +96,10 @@ struct DataGridCellAppearance: Equatable { rawValue: content.rawValue ) let isCursorVisible = state.isFocused && !hasOverlay + // The editor's orange, except over a fill of the cell's own, where it takes the text's color. + let specialCharacterColor: NSColor? = revealsInvisibleCharacters && content.placeholder == nil && kind != .checkbox + ? (findTint != nil || onEmphasizedSelection ? textColor : .systemOrange) + : nil let accessoryRole = DataGridCellAccessoryGlyph.Role( accessory: accessory, isEmphasized: onEmphasizedSelection, @@ -115,7 +122,8 @@ struct DataGridCellAppearance: Equatable { drawsFocusBorder: isCursorVisible && onEmphasizedSelection, drawsFocusRing: isCursorVisible && !onEmphasizedSelection, focusColor: onEmphasizedSelection ? palette.selectionText : palette.focusRing, - checkboxMark: kind == .checkbox ? checkboxMark : nil + checkboxMark: kind == .checkbox ? checkboxMark : nil, + specialCharacterColor: specialCharacterColor ) } diff --git a/apple/TablePro/Views/Results/Cells/DataGridCellRegistry.swift b/apple/TablePro/Views/Results/Cells/DataGridCellRegistry.swift index aea60162ea..03950b6e61 100644 --- a/apple/TablePro/Views/Results/Cells/DataGridCellRegistry.swift +++ b/apple/TablePro/Views/Results/Cells/DataGridCellRegistry.swift @@ -12,6 +12,7 @@ final class DataGridCellRegistry { weak var accessoryDelegate: DataGridCellAccessoryDelegate? private(set) var nullDisplayString: String + private(set) var showsInvisibleCharacters: Bool private(set) var palette: DataGridCellPalette private(set) var scope: ThemeScope private var paletteKey: PaletteKey @@ -28,6 +29,7 @@ final class DataGridCellRegistry { init(scope: ThemeScope = .live) { self.scope = scope nullDisplayString = AppSettingsManager.shared.dataGrid.nullDisplay + showsInvisibleCharacters = AppSettingsManager.shared.dataGrid.showInvisibleCharacters let appearance = NSApp?.effectiveAppearance ?? NSAppearance.currentDrawing() paletteKey = Self.paletteKey(for: appearance) palette = scope.dataGridCellPalette(in: appearance) @@ -35,6 +37,7 @@ final class DataGridCellRegistry { .receive(on: RunLoop.main) .sink { [weak self] _ in self?.nullDisplayString = AppSettingsManager.shared.dataGrid.nullDisplay + self?.showsInvisibleCharacters = AppSettingsManager.shared.dataGrid.showInvisibleCharacters } } diff --git a/apple/TablePro/Views/Results/Cells/DataGridCellRenderer.swift b/apple/TablePro/Views/Results/Cells/DataGridCellRenderer.swift index 582aec2afa..b68658d8b7 100644 --- a/apple/TablePro/Views/Results/Cells/DataGridCellRenderer.swift +++ b/apple/TablePro/Views/Results/Cells/DataGridCellRenderer.swift @@ -5,6 +5,7 @@ import AppKit import CoreText +import TableProTextEngine /// Draws one data cell into a rect. /// @@ -21,6 +22,12 @@ final class DataGridCellRenderer { let font: NSFont let color: NSColor let mark: DataGridCellTextMark? + let specialCharacterColor: NSColor? + } + + private struct LaidOutLine { + let line: CTLine + let specialCharacters: SpecialCharacterLine? } /// A viewport holds a few hundred cells and a scroll reuses them, so this only has to outlive a @@ -31,7 +38,7 @@ final class DataGridCellRenderer { /// wasted on a value the reader opens the inspector to read anyway. private static let maximumLaidOutCharacters = 300 - private var lineCache: [LineKey: CTLine] = [:] + private var lineCache: [LineKey: LaidOutLine] = [:] private let checkboxCell: NSButtonCell = { let cell = NSButtonCell() @@ -75,26 +82,35 @@ final class DataGridCellRenderer { let availableWidth = appearance.accessory.availableTextWidth(in: rect) guard availableWidth > 0, let context = NSGraphicsContext.current?.cgContext else { return } - let fullLine = line(for: appearance.text, appearance: appearance) + let laidOut = line(for: appearance.text, appearance: appearance) + let fullLine = laidOut.line let typographicWidth = CTLineGetTypographicBounds(fullLine, nil, nil, nil) - let ellipsis = line(for: "\u{2026}", appearance: appearance) + let ellipsis = line(for: "\u{2026}", appearance: appearance).line let ellipsisWidth = CTLineGetTypographicBounds(ellipsis, nil, nil, nil) guard Double(availableWidth) >= ellipsisWidth else { return } - let lineToDraw = typographicWidth > Double(availableWidth) - ? (CTLineCreateTruncatedLine(fullLine, Double(availableWidth), .end, ellipsis) ?? ellipsis) - : fullLine + let fits = typographicWidth <= Double(availableWidth) + let truncated = fits ? nil : CTLineCreateTruncatedLine(fullLine, Double(availableWidth), .end, ellipsis) + let lineToDraw = fits ? fullLine : (truncated ?? ellipsis) let font = appearance.font let baselineOffset = (rect.height - font.ascender + font.descender - font.leading) / 2 + font.ascender + let textX = rect.minX + DataGridMetrics.cellHorizontalInset + let baseline = rect.minY + baselineOffset context.saveGState() context.textMatrix = CGAffineTransform(scaleX: 1, y: -1) - context.textPosition = CGPoint( - x: rect.minX + DataGridMetrics.cellHorizontalInset, - y: rect.minY + baselineOffset - ) + context.textPosition = CGPoint(x: textX, y: baseline) CTLineDraw(lineToDraw, context) + if let specialCharacters = laidOut.specialCharacters, fits || truncated != nil { + SpecialCharacterLine.draw( + specialCharacters.marks(drawnIn: lineToDraw), + fromX: textX, + top: baseline - font.ascender, + height: font.ascender - font.descender, + in: context + ) + } context.restoreGState() } @@ -102,19 +118,36 @@ final class DataGridCellRenderer { /// and so `CTLineCreateTruncatedLine` carries it onto a truncated line and its ellipsis. The /// mark is part of the key because two cells holding the same value in the same font and colour /// are two different drawings once one of them is struck through. - private func line(for text: String, appearance: DataGridCellAppearance) -> CTLine { + private func line(for text: String, appearance: DataGridCellAppearance) -> LaidOutLine { let font = appearance.font - let key = LineKey(text: text, font: font, color: appearance.textColor, mark: appearance.textMark) + let key = LineKey( + text: text, + font: font, + color: appearance.textColor, + mark: appearance.textMark, + specialCharacterColor: appearance.specialCharacterColor + ) if let cached = lineCache[key] { return cached } let source = text as NSString - let laidOut = source.length > Self.maximumLaidOutCharacters + var laidOut = source.length > Self.maximumLaidOutCharacters ? source.substring(to: Self.maximumLaidOutCharacters) + "\u{2026}" : text var attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: appearance.textColor] appearance.textMark?.attributes.forEach { attributes[$0.key] = $0.value } - let attributed = NSAttributedString(string: laidOut, attributes: attributes) - let created = CTLineCreateWithAttributedString(attributed as CFAttributedString) + + let created: LaidOutLine + if let markColor = appearance.specialCharacterColor { + let marked = SpecialCharacterLine( + NSAttributedString(string: laidOut, attributes: attributes), + style: SpecialCharacterStyle(color: markColor, marksLineBreaks: true) + ) + created = LaidOutLine(line: marked.line, specialCharacters: marked.marks.isEmpty ? nil : marked) + } else { + laidOut = laidOut.shownWithoutMarks + let attributed = NSAttributedString(string: laidOut, attributes: attributes) + created = LaidOutLine(line: CTLineCreateWithAttributedString(attributed as CFAttributedString), specialCharacters: nil) + } if lineCache.count >= Self.lineCacheLimit { lineCache.removeAll(keepingCapacity: true) diff --git a/apple/TablePro/Views/Results/DataGridCellFactory.swift b/apple/TablePro/Views/Results/DataGridCellFactory.swift index 2628affa55..b0cffce9e8 100644 --- a/apple/TablePro/Views/Results/DataGridCellFactory.swift +++ b/apple/TablePro/Views/Results/DataGridCellFactory.swift @@ -6,6 +6,7 @@ import AppKit import Foundation import TableProPluginKit +import TableProTextEngine @MainActor final class DataGridCellFactory { @@ -40,7 +41,8 @@ final class DataGridCellFactory { displayFormat: ValueDisplayFormat? = nil, databaseType: DatabaseType? = nil, isLargeDataset: Bool = false, - nullDisplayString: String? = nil + nullDisplayString: String? = nil, + revealsInvisibleCharacters: Bool = false ) -> CGFloat { measureColumnWidth( for: columnName, @@ -51,6 +53,7 @@ final class DataGridCellFactory { databaseType: databaseType, isLargeDataset: isLargeDataset, nullDisplayString: nullDisplayString, + revealsInvisibleCharacters: revealsInvisibleCharacters, budget: ColumnWidthBudget( cap: Self.maxColumnWidth, measuredCharLimit: Self.maxMeasureChars, @@ -69,6 +72,7 @@ final class DataGridCellFactory { databaseType: DatabaseType? = nil, isLargeDataset: Bool = false, nullDisplayString: String? = nil, + revealsInvisibleCharacters: Bool = false, fittedColumnCount: Int = 1 ) -> CGFloat { let cap = Self.fitToContentCap(availableWidth: availableWidth) @@ -84,6 +88,7 @@ final class DataGridCellFactory { databaseType: databaseType, isLargeDataset: isLargeDataset, nullDisplayString: nullDisplayString, + revealsInvisibleCharacters: revealsInvisibleCharacters, budget: ColumnWidthBudget( cap: cap, measuredCharLimit: measuredCharLimit, @@ -115,6 +120,7 @@ final class DataGridCellFactory { databaseType: DatabaseType?, isLargeDataset: Bool, nullDisplayString: String?, + revealsInvisibleCharacters: Bool, budget: ColumnWidthBudget ) -> CGFloat { let charWidth = ThemeEngine.shared.dataGridFonts.monoCharWidth @@ -138,17 +144,22 @@ final class DataGridCellFactory { displayFormat: displayFormat, databaseType: databaseType ) ?? "" + let placeholder = DataGridCellContent.placeholder( + for: rawValue, isAbsent: tableRows.isAbsent(row: i, column: columnIndex) + ) let value = DataGridCellContent.resolvedDisplayText( formattedValue, - placeholder: DataGridCellContent.placeholder( - for: rawValue, isAbsent: tableRows.isAbsent(row: i, column: columnIndex) - ), + placeholder: placeholder, isLargeDataset: isLargeDataset, nullDisplayString: resolvedNullDisplayString ) let charCount = min((value as NSString).length, budget.measuredCharLimit) - maxWidth = max(maxWidth, CGFloat(charCount) * charWidth + accessory.measurementPadding) + let markedWidth = revealsInvisibleCharacters && placeholder == nil + ? Self.markedWidth(of: value, length: charCount) + : nil + let textWidth = markedWidth ?? CGFloat(charCount) * charWidth + maxWidth = max(maxWidth, textWidth + accessory.measurementPadding) if maxWidth >= budget.cap { return budget.cap @@ -157,6 +168,24 @@ final class DataGridCellFactory { return min(max(maxWidth, Self.minColumnWidth), budget.cap) } + + // A mark is wider than the character it stands for, so a value holding one is laid out. + private static func markedWidth(of value: String, length: Int) -> CGFloat? { + let text = value as NSString + var index = 0 + while index < length { + let unit = text.character(at: index) + if unit == 0x0D || SpecialCharacter.mayBeSpecial(unit) { break } + index += 1 + } + guard index < length else { return nil } + let font = ThemeEngine.shared.dataGridFonts.regular + let marked = SpecialCharacterLine( + NSAttributedString(string: text.substring(to: length), attributes: [.font: font]), + style: SpecialCharacterStyle(marksLineBreaks: true) + ) + return CGFloat(CTLineGetTypographicBounds(marked.line, nil, nil, nil)) + } } extension NSFont { @@ -182,6 +211,22 @@ internal extension String { } var sanitizedForCellDisplay: String { + flattenedOntoOneLine(keepingLoneCarriageReturns: false) + } + + // A CR with no LF after it ends no line on any current system. It is usually what is left of a + // CRLF split on LF, so a cell keeps it for the renderer to mark. + var flattenedForCell: String { + flattenedOntoOneLine(keepingLoneCarriageReturns: true) + } + + // What a cell shows while marks are off: the lone CR reads as the space it always did. + var shownWithoutMarks: String { + guard utf16.contains(0x0D) else { return self } + return replacingOccurrences(of: "\r", with: " ", options: .literal) + } + + private func flattenedOntoOneLine(keepingLoneCarriageReturns: Bool) -> String { let nsString = self as NSString let length = nsString.length guard length > 0 else { return self } @@ -192,6 +237,9 @@ internal extension String { let ch = nsString.character(at: i) guard ch == 0x0A || ch == 0x0D || ch == 0x0B || ch == 0x0C || ch == 0x85 || ch == 0x2028 || ch == 0x2029 else { continue } + if ch == 0x0D, keepingLoneCarriageReturns, i + 1 >= length || nsString.character(at: i + 1) != 0x0A { + continue + } if mutable == nil { mutable = NSMutableString(capacity: length) diff --git a/apple/TablePro/Views/Results/DataGridCoordinator.swift b/apple/TablePro/Views/Results/DataGridCoordinator.swift index 195bd5b4c8..4fb3fd1e69 100644 --- a/apple/TablePro/Views/Results/DataGridCoordinator.swift +++ b/apple/TablePro/Views/Results/DataGridCoordinator.swift @@ -639,6 +639,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData if dataChanged { reformatDisplayedText() + } else if previous.showInvisibleCharacters != settings.showInvisibleCharacters { + redrawVisibleCells() } } @@ -1089,12 +1091,13 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData let rawValue = row.values[column] let columnType = column < columnTypes.count ? columnTypes[column] : nil guard findSearches(rawValue, column: column, columnType: columnType) else { return nil } - return displayValue( + let shown = displayValue( forID: row.id, column: column, rawValue: rawValue, columnType: columnType ) + return cellRegistry.showsInvisibleCharacters ? shown : shown?.shownWithoutMarks } ) } diff --git a/apple/TablePro/Views/Results/Extensions/DataGridView+ColumnWidths.swift b/apple/TablePro/Views/Results/Extensions/DataGridView+ColumnWidths.swift index 0a74f8b524..262bc5b302 100644 --- a/apple/TablePro/Views/Results/Extensions/DataGridView+ColumnWidths.swift +++ b/apple/TablePro/Views/Results/Extensions/DataGridView+ColumnWidths.swift @@ -15,7 +15,8 @@ extension TableViewCoordinator { displayFormat: columnIndex < columnDisplayFormats.count ? columnDisplayFormats[columnIndex] : nil, databaseType: databaseType, isLargeDataset: isLargeDataset, - nullDisplayString: cellRegistry.nullDisplayString + nullDisplayString: cellRegistry.nullDisplayString, + revealsInvisibleCharacters: cellRegistry.showsInvisibleCharacters ) } @@ -36,6 +37,7 @@ extension TableViewCoordinator { databaseType: databaseType, isLargeDataset: isLargeDataset, nullDisplayString: cellRegistry.nullDisplayString, + revealsInvisibleCharacters: cellRegistry.showsInvisibleCharacters, fittedColumnCount: fittedColumnCount ) } diff --git a/apple/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/apple/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index f2c6542183..cd7777a427 100644 --- a/apple/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/apple/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -105,7 +105,8 @@ extension TableViewCoordinator { onEmphasizedSelection: onEmphasizedSelection, hasOverlay: overlayCell == CellPosition(row: row, column: columnIndex), checkboxMark: checkboxMark(row: row, columnIndex: columnIndex), - textTone: delegate?.dataGridTextTone(row: row, column: columnIndex) + textTone: delegate?.dataGridTextTone(row: row, column: columnIndex), + revealsInvisibleCharacters: cellRegistry.showsInvisibleCharacters ) } @@ -126,7 +127,7 @@ extension TableViewCoordinator { rawValue: rawValue, columnType: columnType ) ?? "" - guard text.isEmpty else { return text } + guard text.isEmpty else { return spokenText(of: text) } switch DataGridCellContent.placeholder(for: rawValue, isAbsent: displayRow.isAbsent(columnIndex)) { case .null: return String(localized: "NULL") @@ -137,6 +138,13 @@ extension TableViewCoordinator { } } + // A mark drawn in the cell is read by its name, or VoiceOver says nothing for it. + private func spokenText(of text: String) -> String { + guard cellRegistry.showsInvisibleCharacters else { return text } + let revealed = RevealedText(text, marksLineBreaks: true) + return revealed.revealsAnyCharacter ? revealed.spokenText : text + } + func tableView(_ tableView: NSTableView, typeSelectStringFor tableColumn: NSTableColumn?, row: Int) -> String? { guard let tableColumn else { return nil } guard tableColumn.identifier != ColumnIdentitySchema.rowNumberIdentifier else { return nil } diff --git a/apple/TablePro/Views/Results/TextViewerWindowController.swift b/apple/TablePro/Views/Results/TextViewerWindowController.swift index 85ba2ff21b..bca006ed2c 100644 --- a/apple/TablePro/Views/Results/TextViewerWindowController.swift +++ b/apple/TablePro/Views/Results/TextViewerWindowController.swift @@ -62,6 +62,9 @@ private struct TextViewerWindowContent: View { isEditable: isEditable, textContainerInset: NSSize(width: 8, height: 10) ) + .safeAreaInset(edge: .bottom, spacing: 0) { + ValueLengthReadout(value: text) + } .onChange(of: text) { _ in guard isEditable else { return } onCommit?(text) diff --git a/apple/TablePro/Views/Results/ValueLengthReadout.swift b/apple/TablePro/Views/Results/ValueLengthReadout.swift new file mode 100644 index 0000000000..c17363a500 --- /dev/null +++ b/apple/TablePro/Views/Results/ValueLengthReadout.swift @@ -0,0 +1,69 @@ +// +// ValueLengthReadout.swift +// TablePro +// + +import SwiftUI + +// Characters are code points, the unit LENGTH and CHAR_LENGTH count, so a value holding an invisible +// character reads one longer than it looks. Bytes are its UTF-8 length. +internal struct ValueLengthSummary: Equatable { + let characterCount: Int + let byteCount: Int + let holdsBidiControl: Bool + + init(_ value: String) { + var characters = 0 + var holdsBidiControl = false + for scalar in value.unicodeScalars { + characters += 1 + if scalar.properties.isBidiControl { holdsBidiControl = true } + } + characterCount = characters + byteCount = value.utf8.count + self.holdsBidiControl = holdsBidiControl + } + + var lengthText: String { + String(format: String(localized: "%1$@, %2$@"), Self.characters(characterCount), Self.bytes(byteCount)) + } + + private static func characters(_ count: Int) -> String { + count == 1 + ? String(localized: "1 character") + : String(format: String(localized: "%@ characters"), count.formatted()) + } + + private static func bytes(_ count: Int) -> String { + count == 1 ? String(localized: "1 byte") : String(format: String(localized: "%@ bytes"), count.formatted()) + } +} + +internal struct ValueLengthReadout: View { + let value: String + + var body: some View { + let summary = ValueLengthSummary(value) + HStack(spacing: StatusBarChrome.clusterSpacing) { + if summary.holdsBidiControl { + Label(Self.bidiWarning, systemImage: "exclamationmark.triangle.fill") + .symbolRenderingMode(.multicolor) + .lineLimit(1) + .truncationMode(.tail) + .help(Self.bidiWarning) + } + Spacer(minLength: 0) + Text(summary.lengthText) + .monospacedDigit() + .help(String(localized: "Characters are Unicode code points. Bytes are the length in UTF-8.")) + } + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityElement(children: .combine) + .statusBarChrome() + } + + private static var bidiWarning: String { + String(localized: "Holds a bidirectional control, which can change the order the text is shown in") + } +} diff --git a/apple/TablePro/Views/Results/ValueSourceEditor.swift b/apple/TablePro/Views/Results/ValueSourceEditor.swift index 201dcc2ce5..21cfa3124d 100644 --- a/apple/TablePro/Views/Results/ValueSourceEditor.swift +++ b/apple/TablePro/Views/Results/ValueSourceEditor.swift @@ -8,6 +8,7 @@ import SwiftUI internal struct ValueSourceEditor: View { @ObservedObject private var themeEngine = ThemeEngine.shared + @ObservedObject private var settingsManager = AppSettingsManager.shared @Binding var text: String var isEditable: Bool var textContainerInset = NSSize(width: 4, height: 6) @@ -18,7 +19,8 @@ internal struct ValueSourceEditor: View { isEditable: isEditable, font: themeEngine.valueFont, textContainerInset: textContainerInset, - style: .code + style: .code, + marksInvisibleCharacters: settingsManager.dataGrid.showInvisibleCharacters ) } } diff --git a/apple/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift b/apple/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift index 3ace38df69..ab3058311c 100644 --- a/apple/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift +++ b/apple/TablePro/Views/RowInspector/FieldEditors/MultiLineEditorView.swift @@ -7,6 +7,7 @@ import SwiftUI internal struct MultiLineEditorView: View { @ObservedObject private var themeEngine = ThemeEngine.shared + @ObservedObject private var settingsManager = AppSettingsManager.shared let context: FieldEditorContext var onPopOut: ((String) -> Void)? var isExpanded = false @@ -25,7 +26,8 @@ internal struct MultiLineEditorView: View { isEditable: !context.isReadOnly, font: themeEngine.valueFont, movesFocusOnTab: true, - style: .code + style: .code, + marksInvisibleCharacters: settingsManager.dataGrid.showInvisibleCharacters ) .valueWell(clipsContent: true) .overlay(alignment: .topLeading) { placeholder } diff --git a/apple/TablePro/Views/Settings/Sections/DataGridSection.swift b/apple/TablePro/Views/Settings/Sections/DataGridSection.swift index 40561049ea..d4b94ba4e9 100644 --- a/apple/TablePro/Views/Settings/Sections/DataGridSection.swift +++ b/apple/TablePro/Views/Settings/Sections/DataGridSection.swift @@ -37,6 +37,8 @@ struct DataGridSection: View { Toggle("Show row numbers", isOn: $settings.showRowNumbers) Toggle("Auto-show inspector on row select", isOn: $settings.autoShowInspector) Toggle("Smart value detection", isOn: $settings.enableSmartValueDetection) + Toggle("Show invisible characters", isOn: $settings.showInvisibleCharacters) + .help(String(localized: "Mark zero-width, control and special space characters in cells and value fields")) Picker("Default row sort:", selection: $settings.defaultSortBehavior) { ForEach(DefaultSortBehavior.allCases) { behavior in diff --git a/apple/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift b/apple/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift index b08a6bfed4..39a2e83c51 100644 --- a/apple/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift +++ b/apple/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift @@ -5,6 +5,7 @@ import Foundation import TableProGeometry import TableProPluginKit +import TableProTextEngine @MainActor internal enum FieldEditorResolver { @@ -35,7 +36,8 @@ internal enum FieldEditorResolver { isLongText: Bool, originalValue: String?, displayFormatOverride: ValueDisplayFormat? = nil, - isBinaryValue: Bool = false + isBinaryValue: Bool = false, + revealsInvisibleCharacters: Bool = false ) -> FieldEditorKind { let structuredAllowed: Bool if let override = displayFormatOverride { @@ -88,7 +90,7 @@ internal enum FieldEditorResolver { if BlobFormattingService.shared.requiresFormatting(columnType: type) { return .blobHex } - if isLongText || needsMultiLineEditor(originalValue) { + if isLongText || needsMultiLineEditor(originalValue, revealsInvisibleCharacters: revealsInvisibleCharacters) { return .multiLine } return .singleLine @@ -191,11 +193,18 @@ internal enum FieldEditorResolver { /// `isLongText` only matches six exact type names, so a large value in `VARCHAR(MAX)`, /// `NCLOB` or ClickHouse's `Nullable(String)` never reached the multi-line editor. Whether a /// value belongs on one line is a property of the value, so ask the value as well. - static func needsMultiLineEditor(_ value: String?) -> Bool { + /// A one-line field cannot mark an invisible character, so a value holding one takes the text + /// view that can, while the marks are on. + static func needsMultiLineEditor(_ value: String?, revealsInvisibleCharacters: Bool = false) -> Bool { guard let value, !value.isEmpty else { return false } let text = value as NSString if text.length > multiLineValueThreshold { return true } - return text.rangeOfCharacter(from: .newlines).location != NSNotFound + if text.rangeOfCharacter(from: .newlines).location != NSNotFound { return true } + return revealsInvisibleCharacters && holdsInvisibleCharacter(text) + } + + private static func holdsInvisibleCharacter(_ text: NSString) -> Bool { + (0 ..< text.length).contains { SpecialCharacter.classify(in: text, at: $0) != nil } } /// Two lines' worth at the inspector's minimum width. The value font is user-configurable, so how diff --git a/apple/TablePro/Views/Shared/FieldEditors/InvisibleCharacterTextDisplay.swift b/apple/TablePro/Views/Shared/FieldEditors/InvisibleCharacterTextDisplay.swift new file mode 100644 index 0000000000..5013e44f89 --- /dev/null +++ b/apple/TablePro/Views/Shared/FieldEditors/InvisibleCharacterTextDisplay.swift @@ -0,0 +1,99 @@ +// +// InvisibleCharacterTextDisplay.swift +// TablePro +// + +import AppKit +import TableProTextEngine + +// Marks a TextKit 2 view's invisible characters as the editor does. Each paragraph is laid out from a +// copy with every mark an attachment of the same UTF-16 length, so editing and copy see the real text. +internal final class InvisibleCharacterTextDisplay: NSObject, NSTextContentStorageDelegate { + private static let objectReplacement = "\u{FFFC}" + private static let clusterExtender = "\u{034F}" + // The editor's cap per line: past it a value of control bytes would lay out one attachment each. + private static let maximumMarksPerParagraph = SpecialCharacterStyle().maximumMarksPerLine + + private weak var contentStorage: NSTextContentStorage? + + internal var isEnabled: Bool { + didSet { + guard isEnabled != oldValue else { return } + relayOut() + } + } + + private init(contentStorage: NSTextContentStorage, isEnabled: Bool) { + self.contentStorage = contentStorage + self.isEnabled = isEnabled + } + + // Nil for a TextKit 1 view, which has no paragraphs to swap. The caller holds the result: + // the content storage keeps its delegate weakly. + @MainActor + internal static func install(on textView: NSTextView, isEnabled: Bool) -> InvisibleCharacterTextDisplay? { + guard let storage = textView.textLayoutManager?.textContentManager as? NSTextContentStorage else { return nil } + let display = InvisibleCharacterTextDisplay(contentStorage: storage, isEnabled: isEnabled) + storage.delegate = display + // A view already laid out keeps its cached paragraphs until asked for them again. + if isEnabled { + display.relayOut() + } + return display + } + + internal func textContentStorage( + _ textContentStorage: NSTextContentStorage, + textParagraphWith range: NSRange + ) -> NSTextParagraph? { + guard isEnabled, let storage = textContentStorage.textStorage else { return nil } + let text = storage.string as NSString + let marked = Self.specialCharacters(in: text, range: range) + guard !marked.isEmpty else { return nil } + + let paragraph = NSMutableAttributedString(attributedString: storage.attributedSubstring(from: range)) + for classified in marked { + let local = NSRange(location: classified.range.location - range.location, length: classified.range.length) + var attributes = paragraph.attributes(at: local.location, effectiveRange: nil) + let font = attributes[.font] as? NSFont ?? .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) + let mark = SpecialCharacterMarkImage(classified, in: text, font: font, color: .systemOrange) + let attachment = NSTextAttachment() + attachment.image = mark.image + attachment.bounds = mark.bounds + + let placeholder = NSMutableAttributedString( + string: String(repeating: Self.clusterExtender, count: local.length - 1), + attributes: attributes + ) + attributes[.attachment] = attachment + placeholder.insert(NSAttributedString(string: Self.objectReplacement, attributes: attributes), at: 0) + paragraph.replaceCharacters(in: local, with: placeholder) + } + return NSTextParagraph(attributedString: paragraph) + } + + private static func specialCharacters(in text: NSString, range: NSRange) -> [ClassifiedSpecialCharacter] { + var found: [ClassifiedSpecialCharacter] = [] + var index = range.location + let end = min(NSMaxRange(range), text.length) + while index < end, found.count < maximumMarksPerParagraph { + guard let classified = SpecialCharacter.classify(in: text, at: index), + NSMaxRange(classified.range) <= end else { + index += 1 + continue + } + found.append(classified) + index = NSMaxRange(classified.range) + } + return found + } + + // An attributes-only edit makes the content storage ask for every paragraph again, leaving the + // text, the selection and the undo stack as they were. + private func relayOut() { + guard let contentStorage, let storage = contentStorage.textStorage, storage.length > 0 else { return } + contentStorage.performEditingTransaction { + storage.edited(.editedAttributes, range: NSRange(location: 0, length: storage.length), changeInLength: 0) + } + } +} diff --git a/apple/TablePro/Views/Shared/FieldEditors/TextValueEditor.swift b/apple/TablePro/Views/Shared/FieldEditors/TextValueEditor.swift index a3200f0e72..93bf43bc6c 100644 --- a/apple/TablePro/Views/Shared/FieldEditors/TextValueEditor.swift +++ b/apple/TablePro/Views/Shared/FieldEditors/TextValueEditor.swift @@ -21,6 +21,7 @@ internal struct TextValueEditor: NSViewRepresentable { var movesFocusOnTab: Bool = false var textContainerInset = NSSize(width: 4, height: 6) var style: Style = .plain + var marksInvisibleCharacters = false func makeNSView(context: Context) -> NSScrollView { let scrollView = NSTextView.scrollableTextView() @@ -93,6 +94,15 @@ internal struct TextValueEditor: NSViewRepresentable { textView.isSelectable = true } applyStyle(to: textView, coordinator: coordinator) + applyInvisibleCharacterMarks(to: textView, coordinator: coordinator) + } + + private func applyInvisibleCharacterMarks(to textView: NSTextView, coordinator: Coordinator) { + if let display = coordinator.invisibleCharacters { + display.isEnabled = marksInvisibleCharacters + } else if marksInvisibleCharacters { + coordinator.invisibleCharacters = InvisibleCharacterTextDisplay.install(on: textView, isEnabled: true) + } } private func applyStyle(to textView: NSTextView, coordinator: Coordinator) { @@ -114,6 +124,7 @@ internal struct TextValueEditor: NSViewRepresentable { /// would run on every SwiftUI update pass. var lastAppliedText: String = "" var themeStyle: ThemeTextViewStyle? + var invisibleCharacters: InvisibleCharacterTextDisplay? init(text: Binding, movesFocusOnTab: Bool) { self.text = text diff --git a/apple/TableProTests/Core/Services/CellDisplayFormatterTests.swift b/apple/TableProTests/Core/Services/CellDisplayFormatterTests.swift index e1fcfa2592..13b79f853b 100644 --- a/apple/TableProTests/Core/Services/CellDisplayFormatterTests.swift +++ b/apple/TableProTests/Core/Services/CellDisplayFormatterTests.swift @@ -159,10 +159,10 @@ struct CellDisplayFormatterTests { #expect(result == "hello world") } - @Test("text with linebreaks is sanitized") + @Test("text with linebreaks is sanitized, and a lone CR stays for the cell to mark") func linebreaksSanitized() { - let result = CellDisplayFormatter.format(.text("line1\nline2\rline3"), columnType: nil) - #expect(result == "line1 line2 line3") + let result = CellDisplayFormatter.format(.text("line1\nline2\rline3\r\nline4"), columnType: nil) + #expect(result == "line1 line2\rline3 line4") } @Test("text over max length is truncated") diff --git a/apple/TableProTests/Views/Results/DataGridInvisibleCharacterTests.swift b/apple/TableProTests/Views/Results/DataGridInvisibleCharacterTests.swift new file mode 100644 index 0000000000..ad60d29845 --- /dev/null +++ b/apple/TableProTests/Views/Results/DataGridInvisibleCharacterTests.swift @@ -0,0 +1,187 @@ +// +// DataGridInvisibleCharacterTests.swift +// TableProTests +// + +import AppKit +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +struct DataGridInvisibleCharacterTests { + private let palette = DataGridCellPalette( + regularFont: .monospacedSystemFont(ofSize: 13, weight: .regular), + italicFont: .monospacedSystemFont(ofSize: 13, weight: .regular), + mediumFont: .monospacedSystemFont(ofSize: 13, weight: .medium), + text: .black, + placeholderText: .secondaryLabelColor, + booleanTrueText: .black, + booleanFalseText: .black, + rowNumberText: .secondaryLabelColor, + deletedRowText: .systemRed, + modifiedColumnTint: .systemYellow, + findMatchTint: .systemYellow, + selectionText: .white + ) + + private func resolve( + _ text: String, + kind: DataGridCellKind = .text, + placeholder: DataGridCellPlaceholder? = nil, + reveals: Bool = true, + isCurrentFindMatch: Bool = false, + onEmphasizedSelection: Bool = false + ) -> DataGridCellAppearance { + DataGridCellAppearance.resolve( + kind: kind, + content: DataGridCellContent(displayText: text, rawValue: text, placeholder: placeholder), + state: DataGridCellState( + visualState: .empty, + isFocused: false, + isEditable: true, + isLargeDataset: false, + isCurrentFindMatch: isCurrentFindMatch, + row: 0, + columnIndex: 0 + ), + palette: palette, + nullDisplayString: "NULL", + onEmphasizedSelection: onEmphasizedSelection, + hasOverlay: false, + revealsInvisibleCharacters: reveals + ) + } + + // MARK: - Which cells are marked + + @Test("A value is marked in orange while the setting is on, and not at all while it is off") + func markColorFollowsTheSetting() { + #expect(resolve("abc\u{200B}").specialCharacterColor == .systemOrange) + #expect(resolve("abc\u{200B}", reveals: false).specialCharacterColor == nil) + } + + @Test("A placeholder and a checkbox are never marked") + func placeholdersAreNotValues() { + #expect(resolve("", placeholder: .null).specialCharacterColor == nil) + #expect(resolve("", placeholder: .empty).specialCharacterColor == nil) + #expect(resolve("1", kind: .checkbox).specialCharacterColor == nil) + } + + @Test("Over a selection or a find match the marks take the text's own color") + func marksFollowTheCellFill() { + #expect(resolve("a\u{200B}", onEmphasizedSelection: true).specialCharacterColor == palette.selectionText) + #expect(resolve("a\u{200B}", isCurrentFindMatch: true).specialCharacterColor == palette.findMatchText) + } + + // MARK: - The display text + + @Test("The cell text keeps a lone CR and flattens LF and CRLF") + func loneCarriageReturnSurvivesFlattening() { + #expect(CellDisplayFormatter.format(.text("abc\r"), columnType: nil) == "abc\r") + #expect(CellDisplayFormatter.format(.text("a\r\nb\nc"), columnType: nil) == "a b c") + #expect("abc\r".sanitizedForCellDisplay == "abc ") + #expect("abc\r".shownWithoutMarks == "abc ") + #expect(ColumnDistinctValue(display: "abc\r", isNull: false, count: 1).label == "abc ") + } + + @Test("A marked CR is read by name, as every other mark is") + func carriageReturnIsSpoken() { + let revealed = RevealedText("abc\r", marksLineBreaks: true) + + #expect(revealed.revealsAnyCharacter) + #expect(revealed.spokenText.contains("carriage return")) + #expect(!RevealedText("abc\r").revealsAnyCharacter) + } + + @Test("Size to Fit makes room for the marks a value draws") + func fitWidthCountsTheMarks() { + let rows = TableRows.from( + queryRows: [[.text(String(repeating: "x", count: 20) + "\u{200B}\u{FEFF}")]], + columns: ["v"], + columnTypes: [.text(rawType: nil)] + ) + let factory = DataGridCellFactory() + let marked = factory.calculateFitToContentWidth( + for: "v", columnIndex: 0, tableRows: rows, availableWidth: 1_600, revealsInvisibleCharacters: true + ) + let unmarked = factory.calculateFitToContentWidth( + for: "v", columnIndex: 0, tableRows: rows, availableWidth: 1_600, revealsInvisibleCharacters: false + ) + + #expect(marked > unmarked + 20) + } + + // MARK: - What is drawn + + private static let cell = NSRect(x: 0, y: 0, width: 200, height: 24) + + @Test("A zero-width space draws an orange box after the text, and nothing while marks are off") + func zeroWidthSpaceIsDrawn() throws { + let marked = try ink(of: resolve("abc\u{200B}")) + let unmarked = try ink(of: resolve("abc\u{200B}", reveals: false)) + let plain = try ink(of: resolve("abc")) + let textEnd = DataGridMetrics.cellHorizontalInset + + ("abc" as NSString).size(withAttributes: [.font: palette.regularFont]).width + + #expect(marked.orange > 0) + #expect(CGFloat(marked.reach) > textEnd + 8) + #expect(unmarked.orange == 0) + #expect(unmarked.reach == plain.reach) + } + + @Test("A lone CR draws as a box while marked and as the old space while not") + func carriageReturnIsDrawn() throws { + #expect(try ink(of: resolve("abc\r")).orange > 0) + #expect(try ink(of: resolve("abc\r", reveals: false)) == ink(of: resolve("abc ", reveals: false))) + } + + @Test("Marks past the ellipsis of a truncated value are not drawn") + func truncatedMarksStayHidden() throws { + let long = String(repeating: "0123456789", count: 6) + "\u{200B}" + #expect(try ink(of: resolve(long)).orange == 0) + #expect(try ink(of: resolve("\u{200B}" + long)).orange > 0) + } + + private struct Ink: Equatable { + let orange: Int + let reach: Int + } + + private func ink(of appearance: DataGridCellAppearance) throws -> Ink { + let host = CellHost(frame: Self.cell) + host.cellAppearance = appearance + let rep = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + NSAppearance(named: .aqua)?.performAsCurrentDrawingAppearance { + host.cacheDisplay(in: host.bounds, to: rep) + } + let scale = max(rep.pixelsWide / Int(Self.cell.width), 1) + var orange = 0 + var reach = 0 + for y in 0 ..< rep.pixelsHigh { + for x in 0 ..< rep.pixelsWide { + guard let pixel = rep.colorAt(x: x, y: y)?.usingColorSpace(.sRGB), + pixel.redComponent < 0.9 || pixel.greenComponent < 0.9 || pixel.blueComponent < 0.9 else { continue } + reach = max(reach, x / scale) + if pixel.redComponent > 0.8, pixel.greenComponent > 0.3, pixel.greenComponent < 0.75, pixel.blueComponent < 0.3 { + orange += 1 + } + } + } + return Ink(orange: orange, reach: reach) + } + + private final class CellHost: NSView { + var cellAppearance: DataGridCellAppearance? + + override var isFlipped: Bool { true } + + override func draw(_ dirtyRect: NSRect) { + NSColor.white.setFill() + bounds.fill() + guard let cellAppearance else { return } + MainActor.assumeIsolated { DataGridCellRenderer().draw(cellAppearance, in: bounds) } + } + } +} diff --git a/apple/TableProTests/Views/Results/InvisibleCharacterSettingTests.swift b/apple/TableProTests/Views/Results/InvisibleCharacterSettingTests.swift new file mode 100644 index 0000000000..82d07fa546 --- /dev/null +++ b/apple/TableProTests/Views/Results/InvisibleCharacterSettingTests.swift @@ -0,0 +1,78 @@ +// +// InvisibleCharacterSettingTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import TableProPluginKit +import TableProTestSupport +import Testing + +extension GlobalStateSuites { + @MainActor + struct InvisibleCharacterSettingTests { + @Test( + "A field holding an invisible character changes editor when the setting changes under it", + .appSettings { $0.dataGrid.showInvisibleCharacters = true } + ) + func editorFollowsTheSetting() async throws { + let state = MultiRowEditState() + state.configure( + selectedRowIndices: [0], + allRows: [["abc\u{200B}", "plain"]], + columns: ["marked", "plain"], + columnTypes: [.text(rawType: "VARCHAR(20)"), .text(rawType: "VARCHAR(20)")] + ) + #expect(state.fields[0].resolvedEditor == .multiLine) + #expect(state.fields[1].resolvedEditor == .singleLine) + + AppSettingsManager.shared.dataGrid.showInvisibleCharacters = false + try await waitUntil { state.fields[0].resolvedEditor == .singleLine } + #expect(state.fields[1].resolvedEditor == .singleLine) + + AppSettingsManager.shared.dataGrid.showInvisibleCharacters = true + try await waitUntil { state.fields[0].resolvedEditor == .multiLine } + } + + @Test( + "Find matches a lone CR as the space the cell shows while marks are off", + .appSettings { $0.dataGrid.showInvisibleCharacters = false } + ) + func findReadsTheUnmarkedText() { + let coordinator = makeCoordinator(values: ["a\rb"]) + + #expect(coordinator.runFind(term: "a b").count == 1) + } + + @Test( + "Find does not read a marked CR as a space", + .appSettings { $0.dataGrid.showInvisibleCharacters = true } + ) + func findReadsTheMarkedText() { + let coordinator = makeCoordinator(values: ["a\rb"]) + + #expect(coordinator.runFind(term: "a b").isEmpty) + #expect(coordinator.runFind(term: "b").count == 1) + } + + private func makeCoordinator(values: [String]) -> TableViewCoordinator { + let coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: nil, + layoutPersister: InMemoryColumnLayoutStore() + ) + let rows = ContiguousArray(values.enumerated().map { index, value in + Row(id: .existing(index), values: [PluginCellValue.text(value)]) + }) + var captured = TableRows(rows: rows, columns: ["v"], columnTypes: [.text(rawType: nil)]) + coordinator.tableRowsProvider = { captured } + coordinator.tableRowsMutator = { (mutation: (inout TableRows) -> Delta) in mutation(&captured) } + coordinator.updateCache() + return coordinator + } + } +} diff --git a/apple/TableProTests/Views/Results/ValueLengthSummaryTests.swift b/apple/TableProTests/Views/Results/ValueLengthSummaryTests.swift new file mode 100644 index 0000000000..9136c88a26 --- /dev/null +++ b/apple/TableProTests/Views/Results/ValueLengthSummaryTests.swift @@ -0,0 +1,42 @@ +// +// ValueLengthSummaryTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +struct ValueLengthSummaryTests { + @Test("A trailing zero-width space reads as one more character and three more bytes") + func zeroWidthSpace() { + let summary = ValueLengthSummary("abc\u{200B}") + + #expect(summary.characterCount == 4) + #expect(summary.byteCount == 6) + #expect(!summary.holdsBidiControl) + } + + @Test("Characters are code points, so a decomposed accent counts twice") + func codePoints() { + let summary = ValueLengthSummary("e\u{0301}") + + #expect(summary.characterCount == 2) + #expect(summary.byteCount == 3) + } + + @Test("Every bidirectional control raises the warning, and other format characters do not") + func bidiControls() { + for control in ["\u{202E}", "\u{2066}", "\u{200F}", "\u{061C}"] { + #expect(ValueLengthSummary("abc\(control)").holdsBidiControl, "\(control.unicodeScalars.first?.value ?? 0)") + } + #expect(!ValueLengthSummary("abc\u{200B}\u{FEFF}").holdsBidiControl) + } + + @Test("The readout names both counts, singular where it is one") + func lengthText() { + #expect(ValueLengthSummary("abc\u{200B}").lengthText == "4 characters, 6 bytes") + #expect(ValueLengthSummary("a").lengthText == "1 character, 1 byte") + #expect(ValueLengthSummary("").lengthText == "0 characters, 0 bytes") + } +} diff --git a/apple/TableProTests/Views/Shared/FieldEditorResolverTests.swift b/apple/TableProTests/Views/Shared/FieldEditorResolverTests.swift index 45b9d42160..d78c026da9 100644 --- a/apple/TableProTests/Views/Shared/FieldEditorResolverTests.swift +++ b/apple/TableProTests/Views/Shared/FieldEditorResolverTests.swift @@ -116,6 +116,23 @@ struct FieldEditorResolverTests { #expect(kind == .singleLine) } + @Test("a short value holding an invisible character opens the text view that marks it, while marks are on") + func invisibleCharacterNeedsTheTextView() { + let type = ColumnType.text(rawType: "VARCHAR(255)") + for value in ["abc\u{200B}", "1\u{00A0}000", "\u{FEFF}id", "a\u{202E}b"] { + #expect( + FieldEditorResolver.resolve(for: type, isLongText: false, originalValue: value, revealsInvisibleCharacters: true) + == .multiLine, + "\(value.unicodeScalars.map { String($0.value, radix: 16) })" + ) + #expect(FieldEditorResolver.resolve(for: type, isLongText: false, originalValue: value) == .singleLine) + } + #expect( + FieldEditorResolver.resolve(for: type, isLongText: false, originalValue: "hello", revealsInvisibleCharacters: true) + == .singleLine + ) + } + @Test("an empty long-text column still opens the multi-line editor") func emptyLongTextColumnStaysMultiLine() { let kind = FieldEditorResolver.resolve( diff --git a/apple/TableProTests/Views/Shared/InvisibleCharacterTextDisplayTests.swift b/apple/TableProTests/Views/Shared/InvisibleCharacterTextDisplayTests.swift new file mode 100644 index 0000000000..bdaefa1f4b --- /dev/null +++ b/apple/TableProTests/Views/Shared/InvisibleCharacterTextDisplayTests.swift @@ -0,0 +1,107 @@ +// +// InvisibleCharacterTextDisplayTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import Testing + +@MainActor +struct InvisibleCharacterTextDisplayTests { + private func makeTextView() throws -> NSTextView { + let scrollView = NSTextView.scrollableTextView() + scrollView.frame = NSRect(x: 0, y: 0, width: 400, height: 100) + let textView = try #require(scrollView.documentView as? NSTextView) + textView.font = .monospacedSystemFont(ofSize: 13, weight: .regular) + return textView + } + + private func lineWidth(of textView: NSTextView) -> CGFloat { + guard let layout = textView.textLayoutManager else { return 0 } + var width: CGFloat = 0 + layout.enumerateTextLayoutFragments(from: layout.documentRange.location, options: [.ensuresLayout]) { fragment in + for line in fragment.textLineFragments { + width = max(width, line.typographicBounds.width) + } + return true + } + return width + } + + private func paragraph( + _ display: InvisibleCharacterTextDisplay, + of textView: NSTextView + ) throws -> NSTextParagraph? { + let storage = try #require(textView.textLayoutManager?.textContentManager as? NSTextContentStorage) + return display.textContentStorage(storage, textParagraphWith: NSRange(location: 0, length: (textView.string as NSString).length)) + } + + @Test("A zero-width space takes a marker's room, and the stored text stays as it was") + func markerTakesRoom() throws { + let plain = try makeTextView() + plain.string = "abc\u{200B}" + let marked = try makeTextView() + let display = try #require(InvisibleCharacterTextDisplay.install(on: marked, isEnabled: true)) + marked.string = "abc\u{200B}" + + #expect(lineWidth(of: marked) > lineWidth(of: plain) + 10) + #expect(marked.string == "abc\u{200B}") + withExtendedLifetime(display) {} + } + + @Test("Installed on a view already showing its text, the marks appear without an edit") + func installAfterLayout() throws { + let textView = try makeTextView() + textView.string = "abc\u{200B}" + let unmarkedWidth = lineWidth(of: textView) + let display = try #require(InvisibleCharacterTextDisplay.install(on: textView, isEnabled: true)) + + #expect(lineWidth(of: textView) > unmarkedWidth + 10) + withExtendedLifetime(display) {} + } + + @Test("A marked character becomes one attachment in a paragraph of the same length") + func paragraphKeepsItsLength() throws { + let textView = try makeTextView() + let display = try #require(InvisibleCharacterTextDisplay.install(on: textView, isEnabled: true)) + textView.string = "a\u{202E}b\u{E0001}c" + + let substituted = try #require(try paragraph(display, of: textView)) + let shown = substituted.attributedString + #expect(shown.length == ("a\u{202E}b\u{E0001}c" as NSString).length) + #expect(shown.attribute(.attachment, at: 1, effectiveRange: nil) is NSTextAttachment) + #expect(shown.attribute(.attachment, at: 3, effectiveRange: nil) is NSTextAttachment) + #expect(shown.attribute(.attachment, at: 4, effectiveRange: nil) == nil) + #expect(shown.attribute(.attachment, at: 0, effectiveRange: nil) == nil) + } + + @Test("Plain text, or marks turned off, keeps the stored paragraph") + func nothingToSwap() throws { + let textView = try makeTextView() + let display = try #require(InvisibleCharacterTextDisplay.install(on: textView, isEnabled: true)) + textView.string = "plain" + #expect(try paragraph(display, of: textView) == nil) + + textView.string = "abc\u{200B}" + display.isEnabled = false + #expect(try paragraph(display, of: textView) == nil) + } + + @Test("Turning marks off and on lays the text out again and keeps the selection") + func toggleRelaysOut() throws { + let textView = try makeTextView() + let display = try #require(InvisibleCharacterTextDisplay.install(on: textView, isEnabled: true)) + textView.string = "abc\u{200B}def" + textView.setSelectedRange(NSRange(location: 2, length: 3)) + let markedWidth = lineWidth(of: textView) + + display.isEnabled = false + let unmarkedWidth = lineWidth(of: textView) + display.isEnabled = true + + #expect(unmarkedWidth < markedWidth - 10) + #expect(lineWidth(of: textView) == markedWidth) + #expect(textView.selectedRange() == NSRange(location: 2, length: 3)) + } +} diff --git a/apple/changelog.d/3728.added.md b/apple/changelog.d/3728.added.md new file mode 100644 index 0000000000..9f26f32fa7 --- /dev/null +++ b/apple/changelog.d/3728.added.md @@ -0,0 +1 @@ +Cells and value fields mark invisible characters, and the text window counts a value's characters and bytes.