diff --git a/Sources/CodeEditTextView/Extensions/CGRectArray+BoundingRect.swift b/Sources/CodeEditTextView/Extensions/CGRectArray+BoundingRect.swift new file mode 100644 index 000000000..5f94249fa --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/CGRectArray+BoundingRect.swift @@ -0,0 +1,23 @@ +// +// File.swift +// CodeEditTextView +// +// Created by Khan Winter on 7/17/25. +// + +import AppKit + +extension Array where Element == CGRect { + /// Returns a rect object that contains all of the rects in this array. + /// Returns `.zero` if the array is empty. + /// - Returns: The minimum rectangle that contains all rectangles in this array. + func boundingRect() -> CGRect { + guard !self.isEmpty else { return .zero } + let minX = self.min(by: { $0.origin.x < $1.origin.x })?.origin.x ?? 0 + let minY = self.min(by: { $0.origin.y < $1.origin.y })?.origin.y ?? 0 + let max = self.max(by: { $0.maxY < $1.maxY }) ?? .zero + let origin = CGPoint(x: minX, y: minY) + let size = CGSize(width: max.maxX - minX, height: max.maxY - minY) + return CGRect(origin: origin, size: size) + } +} diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift index ba0a997ed..f6a5e1ab8 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift @@ -196,7 +196,7 @@ public extension TextLayoutManager { } if lastAttachment.range.max > originalPosition.position.range.max, - var extendedLinePosition = lineStorage.getLine(atOffset: lastAttachment.range.max) { + let extendedLinePosition = lineStorage.getLine(atOffset: lastAttachment.range.max) { newPosition = TextLineStorage.TextLinePosition( data: newPosition.data, range: NSRange(start: newPosition.range.location, end: extendedLinePosition.range.max), diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift index 60b0c7e60..b24dab062 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift @@ -82,13 +82,7 @@ extension TextSelectionManager { context.setFillColor(fillColor) let fillRects = getFillRects(in: rect, for: textSelection) - - let minX = fillRects.min(by: { $0.origin.x < $1.origin.x })?.origin.x ?? 0 - let minY = fillRects.min(by: { $0.origin.y < $1.origin.y })?.origin.y ?? 0 - let max = fillRects.max(by: { $0.maxY < $1.maxY }) ?? .zero - let origin = CGPoint(x: minX, y: minY) - let size = CGSize(width: max.maxX - minX, height: max.maxY - minY) - textSelection.boundingRect = CGRect(origin: origin, size: size) + textSelection.boundingRect = fillRects.boundingRect() context.fill(fillRects) context.restoreGState() diff --git a/Sources/CodeEditTextView/TextView/TextView+Accessibility.swift b/Sources/CodeEditTextView/TextView/TextView+Accessibility.swift index db8454d52..87ebe2f0a 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Accessibility.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Accessibility.swift @@ -9,11 +9,16 @@ import AppKit /// # Notes /// -/// This implementation considers the entire document as one element, ignoring all subviews and lines. +/// ~~This implementation considers the entire document as one element, ignoring all subviews and lines. /// Another idea would be to make each line fragment an accessibility element, with options for navigating through /// lines from there. The text view would then only handle text input, and lines would handle reading out useful data /// to the user. -/// More research needs to be done for the best option here. +/// More research needs to be done for the best option here.~~ +/// +/// Consider that the system has access to the ``TextView/accessibilityVisibleCharacterRange`` and +/// ``TextView/accessibilityString(for:)`` methods. These can combine to allow an accessibility system to efficiently +/// query the text view's contents. Adding accessibility elements to line fragments would require hit testing them, +/// which will cause performance degradation. extension TextView { override open func isAccessibilityElement() -> Bool { true @@ -27,6 +32,11 @@ extension TextView { isFirstResponder } + override open func setAccessibilityFocused(_ accessibilityFocused: Bool) { + guard !isFirstResponder else { return } + window?.makeFirstResponder(self) + } + override open func accessibilityLabel() -> String? { "Text Editor" } @@ -48,7 +58,11 @@ extension TextView { } override open func accessibilityString(for range: NSRange) -> String? { - textStorage.substring( + guard documentRange.intersection(range) == range else { + return nil + } + + return textStorage.substring( from: textStorage.mutableString.rangeOfComposedCharacterSequences(for: range) ) } @@ -56,13 +70,14 @@ extension TextView { // MARK: Selections override open func accessibilitySelectedText() -> String? { - guard let selection = selectionManager - .textSelections - .sorted(by: { $0.range.lowerBound < $1.range.lowerBound }) - .first else { + let selectedRange = accessibilitySelectedTextRange() + guard selectedRange != .notFound else { return nil } - let range = (textStorage.string as NSString).rangeOfComposedCharacterSequences(for: selection.range) + if selectedRange.isEmpty { + return "" + } + let range = (textStorage.string as NSString).rangeOfComposedCharacterSequences(for: selectedRange) return textStorage.substring(from: range) } @@ -71,7 +86,10 @@ extension TextView { .textSelections .sorted(by: { $0.range.lowerBound < $1.range.lowerBound }) .first else { - return .zero + return .notFound + } + if selection.range.isEmpty { + return selection.range } return textStorage.mutableString.rangeOfComposedCharacterSequences(for: selection.range) } @@ -83,12 +101,10 @@ extension TextView { } override open func accessibilityInsertionPointLineNumber() -> Int { - guard let selection = selectionManager - .textSelections - .sorted(by: { $0.range.lowerBound < $1.range.lowerBound }) - .first, - let linePosition = layoutManager.textLineForOffset(selection.range.location) else { - return 0 + let selectedRange = accessibilitySelectedTextRange() + guard selectedRange != .notFound, + let linePosition = layoutManager.textLineForOffset(selectedRange.location) else { + return -1 } return linePosition.index } @@ -122,6 +138,31 @@ extension TextView { } override open func accessibilityRange(for index: Int) -> NSRange { - textStorage.mutableString.rangeOfComposedCharacterSequence(at: index) + guard index < documentRange.length else { return .notFound } + return textStorage.mutableString.rangeOfComposedCharacterSequence(at: index) + } + + override open func accessibilityVisibleCharacterRange() -> NSRange { + visibleTextRange ?? .notFound + } + + /// The line index for a given character offset. + override open func accessibilityLine(for index: Int) -> Int { + guard index <= textStorage.length, + let textLine = layoutManager.textLineForOffset(index) else { + return -1 + } + return textLine.index + } + + override open func accessibilityFrame(for range: NSRange) -> NSRect { + guard documentRange.intersection(range) == range else { + return .zero + } + if range.isEmpty { + return .zero + } + let rects = layoutManager.rectsFor(range: range) + return rects.boundingRect() } } diff --git a/Tests/CodeEditTextViewTests/AccessibilityTests.swift b/Tests/CodeEditTextViewTests/AccessibilityTests.swift new file mode 100644 index 000000000..ed526054b --- /dev/null +++ b/Tests/CodeEditTextViewTests/AccessibilityTests.swift @@ -0,0 +1,299 @@ +// +// AccessibilityTests.swift +// CodeEditTextView +// +// Created by Khan Winter on 7/17/25. +// + +import Testing +import AppKit +@testable import CodeEditTextView + +@MainActor +@Suite +struct AccessibilityTests { + let textView: TextView + let sampleText = "Line 1\nLine 2\nLine 3" + + init() { + textView = TextView(string: sampleText) + textView.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000) + textView.updateFrameIfNeeded() + } + + // MARK: - Basic Accessibility Properties + + @Test + func isAccessibilityElement() { + #expect(textView.isAccessibilityElement()) + } + + @Test + func isAccessibilityEnabled() { + #expect(textView.isAccessibilityEnabled()) + } + + @Test + func accessibilityLabel() { + #expect(textView.accessibilityLabel() == "Text Editor") + } + + @Test + func accessibilityRole() { + #expect(textView.accessibilityRole() == .textArea) + } + + @Test + func accessibilityValue() { + #expect(textView.accessibilityValue() as? String == sampleText) + } + + @Test + func setAccessibilityValue() { + let newValue = "New content" + textView.setAccessibilityValue(newValue) + #expect(textView.string == newValue) + } + + @Test + func setAccessibilityValueInvalidType() { + let originalString = textView.string + textView.setAccessibilityValue(42) + #expect(textView.string == originalString) + } + + // MARK: - Character and String Access + + @Test + func accessibilityNumberOfCharacters() { + #expect(textView.accessibilityNumberOfCharacters() == sampleText.count) + } + + @Test + func accessibilityStringForRange() { + let range = NSRange(location: 0, length: 6) + let result = textView.accessibilityString(for: range) + #expect(result == "Line 1") + } + + @Test + func accessibilityStringForInvalidRange() { + let range = NSRange(location: 100, length: 5) + let result = textView.accessibilityString(for: range) + #expect(result == nil) + } + + @Test + func accessibilityRangeForCharacterIndex() { + let range = textView.accessibilityRange(for: 0) + #expect(range.location == 0) + #expect(range.length == 1) + } + + @Test + func accessibilityRangeForInvalidIndex() { + let range = textView.accessibilityRange(for: 1000) + #expect(range == .notFound) + } + + // MARK: - Selection Tests + + @Test + func accessibilitySelectedTextNoSelections() { + textView.selectionManager.setSelectedRanges([]) + #expect(textView.accessibilitySelectedText() == nil) + } + + @Test + func accessibilitySelectedTextEmpty() { + textView.selectionManager.setSelectedRange(.zero) + #expect(textView.accessibilitySelectedText() == "") + } + + @Test + func accessibilitySelectedText() { + let range = NSRange(location: 0, length: 6) + textView.selectionManager.setSelectedRange(range) + #expect(textView.accessibilitySelectedText() == "Line 1") + } + + @Test + func accessibilitySelectedTextRange() { + let range = NSRange(location: 2, length: 4) + textView.selectionManager.setSelectedRange(range) + let selectedRange = textView.accessibilitySelectedTextRange() + #expect(selectedRange.location == 2) + #expect(selectedRange.length == 4) + } + + @Test + func accessibilitySelectedTextRangeEmpty() { + textView.selectionManager.setSelectedRange(.zero) + let selectedRange = textView.accessibilitySelectedTextRange() + #expect(selectedRange == .zero) + } + + @Test + func setAccessibilitySelectedTextRange() { + let range = NSRange(location: 7, length: 6) + textView.setAccessibilitySelectedTextRange(range) + #expect(textView.accessibilitySelectedTextRange() == range) + } + + @Test + func accessibilitySelectedTextRanges() { + let ranges = [ + NSRange(location: 0, length: 4), + NSRange(location: 7, length: 6) + ] + textView.selectionManager.setSelectedRanges(ranges) + let selectedRanges = textView.accessibilitySelectedTextRanges()?.compactMap { $0 as? NSRange } + #expect(selectedRanges?.count == 2) + #expect(selectedRanges?.contains(ranges[0]) == true) + #expect(selectedRanges?.contains(ranges[1]) == true) + } + + @Test + func setAccessibilitySelectedTextRanges() { + let ranges = [ + NSRange(location: 0, length: 4) as NSValue, + NSRange(location: 7, length: 6) as NSValue + ] + textView.setAccessibilitySelectedTextRanges(ranges) + let selectedRanges = textView.accessibilitySelectedTextRanges() + #expect(selectedRanges?.count == 2) + } + + @Test + func setAccessibilitySelectedTextRangesNil() { + textView.setAccessibilitySelectedTextRanges(nil) + let selectedRanges = textView.accessibilitySelectedTextRanges() + #expect(selectedRanges?.isEmpty == true) + } + + // MARK: - Line Navigation Tests + + @Test + func accessibilityLineForIndex() { + let lineIndex = textView.accessibilityLine(for: 0) + #expect(lineIndex == 0) + } + + @Test + func accessibilityLineForIndexSecondLine() { + let lineIndex = textView.accessibilityLine(for: 7) + #expect(lineIndex == 1) + } + + @Test + func accessibilityLineForEndOfDocument() { + let lineIndex = textView.accessibilityLine(for: textView.documentRange.max) + #expect(lineIndex == 2) + } + + @Test + func accessibilityLineForInvalidIndex() { + let lineIndex = textView.accessibilityLine(for: 1000) + #expect(lineIndex == -1) + } + + @Test + func accessibilityRangeForLine() { + let range = textView.accessibilityRange(forLine: 0) + #expect(range.location == 0) + #expect(range.length == 7) + } + + @Test + func accessibilityRangeForLineSecondLine() { + let range = textView.accessibilityRange(forLine: 1) + #expect(range.location == 7) + #expect(range.length == 7) + } + + @Test + func accessibilityRangeForInvalidLine() { + let range = textView.accessibilityRange(forLine: 100) + #expect(range == .zero) + } + + @Test + func accessibilityRangeForNegativeLine() { + let range = textView.accessibilityRange(forLine: -1) + #expect(range == .zero) + } + + @Test + func accessibilityInsertionPointLineNumber() { + textView.selectionManager.setSelectedRange(NSRange(location: 7, length: 0)) + let lineNumber = textView.accessibilityInsertionPointLineNumber() + #expect(lineNumber == 1) + } + + @Test + func accessibilityInsertionPointLineNumberEmptySelection() { + textView.selectionManager.setSelectedRange(.zero) + let lineNumber = textView.accessibilityInsertionPointLineNumber() + #expect(lineNumber == 0) + } + + @Test + func accessibilityInsertionPointLineNumberNoSelection() { + textView.selectionManager.setSelectedRanges([]) + let lineNumber = textView.accessibilityInsertionPointLineNumber() + #expect(lineNumber == -1) + } + + // MARK: - Visible Range Tests + + @Test + func accessibilityVisibleCharacterRange() { + let visibleRange = textView.accessibilityVisibleCharacterRange() + #expect(visibleRange != .notFound) + } + + @Test + func accessibilityVisibleCharacterRangeNoVisibleText() { + let emptyTextView = TextView(string: "") + let visibleRange = emptyTextView.accessibilityVisibleCharacterRange() + #expect(visibleRange == .zero) + } + + // MARK: - Point and Frame Tests + + @Test + func accessibilityRangeForPoint() { + let point = NSPoint(x: 10, y: 10) + let range = textView.accessibilityRange(for: point) + #expect(range.length == 0) + } + + @Test + func accessibilityRangeForInvalidPoint() { + let point = NSPoint(x: -100, y: -100) + let range = textView.accessibilityRange(for: point) + #expect(range == .zero) + } + + @Test + func accessibilityFrameForRange() { + let range = NSRange(location: 0, length: 6) + let frame = textView.accessibilityFrame(for: range) + #expect(frame.size.width > 0) + #expect(frame.size.height > 0) + } + + @Test + func accessibilityFrameForEmptyRange() { + let range = NSRange(location: 0, length: 0) + let frame = textView.accessibilityFrame(for: range) + #expect(frame.size.width >= 0) + #expect(frame.size.height >= 0) + } + + @Test + func isAccessibilityFocusedWhenNotFirstResponder() { + textView.window?.makeFirstResponder(nil) + #expect(!textView.isAccessibilityFocused()) + } +}