diff --git a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift index 2bf9e4cf3..42e8fb7bd 100644 --- a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift @@ -49,6 +49,45 @@ extension Ghostty { /// True when the surface is in readonly mode. @Published private(set) var readonly: Bool = false + /// Snapshot of the full screen text with viewport position + /// and line-start offsets precomputed in UTF-16 space (NSRange + /// semantics). + struct ScreenText: Equatable { + static let empty = ScreenText( + text: "", + viewportRange: NSRange(location: 0, length: 0), + utf16Length: 0, + lineStarts: [0] + ) + + let text: String + let viewportRange: NSRange + let utf16Length: Int + + /// UTF-16 offsets of the start of each line. Always + /// non-empty: index 0 is 0 even for an empty string. + let lineStarts: [Int] + + /// Line number (0-based) containing the given UTF-16 + /// offset. Out-of-range offsets clamp to the nearest + /// valid position. + func line(at index: Int) -> Int { + let clamped = max(0, min(index, utf16Length)) + // Upper-bound search over lineStarts. + var lo = 0 + var hi = lineStarts.count + while lo < hi { + let mid = lo + (hi - lo) / 2 + if lineStarts[mid] <= clamped { + lo = mid + 1 + } else { + hi = mid + } + } + return lo - 1 + } + } + /// True when the surface should show a highlight effect (e.g., when presented via goto_split). @Published private(set) var highlighted: Bool = false @@ -179,3 +218,56 @@ extension Ghostty.OSSurfaceView { return true } } + +extension Ghostty.OSSurfaceView.ScreenText { + /// Build from a UTF-8 string and the byte offsets that delimit + /// the viewport, translating to UTF-16 / NSRange space. + init( + text: String, + viewportStartByte: Int, + viewportEndByte: Int + ) { + let utf8 = text.utf8 + let utf16Length = text.utf16.count + + // Convert the viewport from UTF-8 bytes to UTF-16 offsets. + let viewportStart = utf8.index( + utf8.startIndex, offsetBy: viewportStartByte, limitedBy: utf8.endIndex + )?.utf16Offset(in: text) ?? utf16Length + let viewportEnd = max(viewportStart, utf8.index( + utf8.startIndex, offsetBy: viewportEndByte, limitedBy: utf8.endIndex + )?.utf16Offset(in: text) ?? utf16Length) + + // getLineStart's contentsEnd tells us whether the line ended + // with a terminator — including at end-of-buffer, where a + // trailing terminator means an extra empty line AX clients can + // navigate to. Every Unicode line terminator (LF, CR, CRLF, + // NEL, LS, PS) counts. + let string = text as NSString + var lineStarts: [Int] = [0] + var cursor = 0 + while cursor < utf16Length { + var start = 0 + var end = 0 + var contentsEnd = 0 + string.getLineStart( + &start, + end: &end, + contentsEnd: &contentsEnd, + for: NSRange(location: cursor, length: 0) + ) + if end <= cursor { break } + if contentsEnd < end { lineStarts.append(end) } + cursor = end + } + + self.init( + text: text, + viewportRange: NSRange( + location: viewportStart, + length: viewportEnd - viewportStart), + utf16Length: utf16Length, + lineStarts: lineStarts + ) + } +} diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 0958e3d0c..1d339d8bd 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -209,46 +209,7 @@ extension Ghostty { // The cached contents of the screen. private(set) var cachedScreenContents: CachedValue private(set) var cachedVisibleContents: CachedValue - - /// Snapshot of the full screen text with viewport position - /// and line-start offsets precomputed in UTF-16 space (NSRange - /// semantics). - struct ScreenText: Equatable { - static let empty = ScreenText( - text: "", - viewportRange: NSRange(location: 0, length: 0), - utf16Length: 0, - lineStarts: [0] - ) - - let text: String - let viewportRange: NSRange - let utf16Length: Int - - /// UTF-16 offsets of the start of each line. Always - /// non-empty: index 0 is 0 even for an empty string. - let lineStarts: [Int] - - /// Line number (0-based) containing the given UTF-16 - /// offset. Out-of-range offsets clamp to the nearest - /// valid position. - func line(at index: Int) -> Int { - let clamped = max(0, min(index, utf16Length)) - // Upper-bound search over lineStarts. - var lo = 0 - var hi = lineStarts.count - while lo < hi { - let mid = lo + (hi - lo) / 2 - if lineStarts[mid] <= clamped { - lo = mid + 1 - } else { - hi = mid - } - } - return lo - 1 - } - } - private(set) var cachedScreenText: CachedValue + private(set) var cachedScreenText: CachedValue /// Event monitor (see individual events for why) private var eventMonitor: Any? @@ -328,7 +289,7 @@ extension Ghostty { } defer { ghostty_surface_free_screen_text(surface, &info) } guard let cString = info.text else { return .empty } - return ScreenText( + return .init( text: String(cString: cString), viewportStartByte: Int(info.viewport_start), viewportEndByte: Int(info.viewport_end) @@ -2312,60 +2273,6 @@ extension Ghostty.SurfaceView { // MARK: Accessibility -extension Ghostty.SurfaceView.ScreenText { - /// Build from a UTF-8 string and the byte offsets that delimit - /// the viewport, translating to UTF-16 / NSRange space. - init( - text: String, - viewportStartByte: Int, - viewportEndByte: Int - ) { - let utf8 = text.utf8 - let utf16Length = text.utf16.count - - // Convert the viewport from UTF-8 bytes to UTF-16 offsets. - let viewportStart = utf8.index( - utf8.startIndex, offsetBy: viewportStartByte, limitedBy: utf8.endIndex - )?.utf16Offset(in: text) ?? utf16Length - let viewportEnd = max(viewportStart, utf8.index( - utf8.startIndex, offsetBy: viewportEndByte, limitedBy: utf8.endIndex - )?.utf16Offset(in: text) ?? utf16Length) - - // getLineStart's contentsEnd tells us whether the line ended - // with a terminator — including at end-of-buffer, where a - // trailing terminator means an extra empty line AX clients can - // navigate to. Every Unicode line terminator (LF, CR, CRLF, - // NEL, LS, PS) counts. - let string = text as NSString - var lineStarts: [Int] = [0] - var cursor = 0 - while cursor < utf16Length { - var start = 0 - var end = 0 - var contentsEnd = 0 - string.getLineStart( - &start, - end: &end, - contentsEnd: &contentsEnd, - for: NSRange(location: cursor, length: 0) - ) - if end <= cursor { break } - if contentsEnd < end { lineStarts.append(end) } - cursor = end - } - - self.init( - text: text, - viewportRange: NSRange( - location: viewportStart, - length: viewportEnd - viewportStart), - utf16Length: utf16Length, - lineStarts: lineStarts - ) - } - -} - extension Ghostty.SurfaceView { /// Indicates that this view should be exposed to accessibility tools like VoiceOver. /// By returning true, we make the terminal surface accessible to screen readers diff --git a/macos/Tests/Ghostty/Surface View/OSSurfaceView+ScreenTextTests.swift b/macos/Tests/Ghostty/Surface View/OSSurfaceView+ScreenTextTests.swift new file mode 100644 index 000000000..6d16b7e69 --- /dev/null +++ b/macos/Tests/Ghostty/Surface View/OSSurfaceView+ScreenTextTests.swift @@ -0,0 +1,147 @@ +@testable import Ghostty +import Foundation +import Testing + +struct OSSurfaceViewScreenTextTests { + @Test func emptyTextProducesEmpty() { + let screenText = Ghostty.OSSurfaceView.ScreenText( + text: "", + viewportStartByte: 0, + viewportEndByte: 0 + ) + #expect(screenText == .empty) + } + + @Test func asciiOffsetsAreIdentity() { + let screenText = Ghostty.OSSurfaceView.ScreenText( + text: "hello\nworld", + viewportStartByte: 6, + viewportEndByte: 11 + ) + #expect(screenText.utf16Length == 11) + #expect(screenText.viewportRange == NSRange(location: 6, length: 5)) + } + + @Test func emojiCountsAsTwoUTF16Units() { + // U+1F600 ("😀") is 4 bytes in UTF-8 and a surrogate pair (two + // code units) in UTF-16. + let text = "a😀b" + let screenText = Ghostty.OSSurfaceView.ScreenText( + text: text, + viewportStartByte: 0, + viewportEndByte: text.utf8.count + ) + #expect(text.count == 3) + #expect(screenText.utf16Length == 4) + #expect(screenText.viewportRange == NSRange(location: 0, length: 4)) + } + + @Test func viewportRangeSkipsAcrossSurrogatePair() { + let text = "a😀b" + let screenText = Ghostty.OSSurfaceView.ScreenText( + text: text, + viewportStartByte: 5, // byte index of "b" + viewportEndByte: 6 // byte index past "b" + ) + #expect(screenText.viewportRange == NSRange(location: 3, length: 1)) + } + + @Test func cjkCharacterCountsAsOneUTF16Unit() { + let text = "好" + let screenText = Ghostty.OSSurfaceView.ScreenText( + text: text, + viewportStartByte: 0, + viewportEndByte: text.utf8.count + ) + #expect(screenText.utf16Length == 1) + #expect(screenText.viewportRange == NSRange(location: 0, length: 1)) + } + + @Test func viewportPastEndClampsToEndOfText() { + let screenText = Ghostty.OSSurfaceView.ScreenText( + text: "hi", + viewportStartByte: 10, + viewportEndByte: 20 + ) + #expect(screenText.viewportRange.location == 2) + #expect(screenText.viewportRange.length == 0) + } + + @Test func reversedOffsetsCollapseToZeroLength() { + // A negative NSRange.length is meaningless to AX clients; the + // init normalizes to a zero-length range at start. + let screenText = Ghostty.OSSurfaceView.ScreenText( + text: "abcdef", + viewportStartByte: 4, + viewportEndByte: 2 + ) + #expect(screenText.viewportRange.length == 0) + } + + @Test func viewportInMiddleOfPureAscii() { + let text = "scrollback\nviewport line\nmore content" + let viewport = "viewport line\n" + let start = text.utf8.distance( + from: text.utf8.startIndex, + to: text.range(of: viewport)!.lowerBound.samePosition(in: text.utf8)! + ) + let screenText = Ghostty.OSSurfaceView.ScreenText( + text: text, + viewportStartByte: start, + viewportEndByte: start + viewport.utf8.count + ) + #expect(screenText.utf16Length == (text as NSString).length) + #expect(screenText.viewportRange.length == (viewport as NSString).length) + } + + // MARK: lineStarts + + @Test(arguments: [ + // Empty-text case is covered by emptyTextProducesEmpty. + ("hello", [0]), + ("a\nb\nc", [0, 2, 4]), + // CRLF is a single line terminator, not two. + ("a\r\nb", [0, 3]), + // Trailing newline creates an empty trailing line so the + // past-end cursor is reported on its own line. + ("a\nb\n", [0, 2, 4]), + ]) + func lineStarts(text: String, expected: [Int]) { + let screenText = Ghostty.OSSurfaceView.ScreenText( + text: text, viewportStartByte: 0, viewportEndByte: text.utf8.count + ) + #expect(screenText.lineStarts == expected) + } + + @Test(arguments: [ + (0, 0), // before first char + (1, 0), // on the '\n' + (2, 1), // start of second line + (3, 1), // inside second line + (4, 2), // start of third line + (5, 2), // past end clamps to last line + (100, 2) // way past end still clamps + ]) + func lineAtIndex(index: Int, expected: Int) { + let screenText = Ghostty.OSSurfaceView.ScreenText( + text: "a\nb\nc", viewportStartByte: 0, viewportEndByte: 5 + ) + #expect(screenText.line(at: index) == expected) + } + + @Test func lineAtNegativeIndexClampsToZero() { + let screenText = Ghostty.OSSurfaceView.ScreenText( + text: "a\nb", viewportStartByte: 0, viewportEndByte: 3 + ) + #expect(screenText.line(at: -1) == 0) + } + + @Test func lineForEmptyText() { + // Past-end indices clamp to line 0 when the text is empty; + // the in-range `at: 0` case is covered by lineAtIndex. + let screenText = Ghostty.OSSurfaceView.ScreenText( + text: "", viewportStartByte: 0, viewportEndByte: 0 + ) + #expect(screenText.line(at: 100) == 0) + } +} diff --git a/macos/Tests/Ghostty/Surface View/SurfaceView+AccessibilityTests.swift b/macos/Tests/Ghostty/Surface View/SurfaceView+AccessibilityTests.swift index 6a12db8be..fad35c3f3 100644 --- a/macos/Tests/Ghostty/Surface View/SurfaceView+AccessibilityTests.swift +++ b/macos/Tests/Ghostty/Surface View/SurfaceView+AccessibilityTests.swift @@ -2,156 +2,7 @@ import Foundation import Testing -/// Tests for the pure-logic helpers that back the macOS accessibility -/// overrides on `SurfaceView`. The full instantiation path needs a -/// live `ghostty_app_t`, so we exercise the conversion layer that -/// translates UTF-8 byte offsets from the Zig core into UTF-16 -/// (NSRange) space. struct SurfaceViewAccessibilityTests { - @Test func emptyTextProducesEmpty() { - let screenText = Ghostty.SurfaceView.ScreenText( - text: "", - viewportStartByte: 0, - viewportEndByte: 0 - ) - #expect(screenText == .empty) - } - - @Test func asciiOffsetsAreIdentity() { - let screenText = Ghostty.SurfaceView.ScreenText( - text: "hello\nworld", - viewportStartByte: 6, - viewportEndByte: 11 - ) - #expect(screenText.utf16Length == 11) - #expect(screenText.viewportRange == NSRange(location: 6, length: 5)) - } - - @Test func emojiCountsAsTwoUTF16Units() { - // U+1F600 ("😀") is 4 bytes in UTF-8 and a surrogate pair (two - // code units) in UTF-16. - let text = "a😀b" - let screenText = Ghostty.SurfaceView.ScreenText( - text: text, - viewportStartByte: 0, - viewportEndByte: text.utf8.count - ) - #expect(text.count == 3) - #expect(screenText.utf16Length == 4) - #expect(screenText.viewportRange == NSRange(location: 0, length: 4)) - } - - @Test func viewportRangeSkipsAcrossSurrogatePair() { - let text = "a😀b" - let screenText = Ghostty.SurfaceView.ScreenText( - text: text, - viewportStartByte: 5, // byte index of "b" - viewportEndByte: 6 // byte index past "b" - ) - #expect(screenText.viewportRange == NSRange(location: 3, length: 1)) - } - - @Test func cjkCharacterCountsAsOneUTF16Unit() { - let text = "好" - let screenText = Ghostty.SurfaceView.ScreenText( - text: text, - viewportStartByte: 0, - viewportEndByte: text.utf8.count - ) - #expect(screenText.utf16Length == 1) - #expect(screenText.viewportRange == NSRange(location: 0, length: 1)) - } - - @Test func viewportPastEndClampsToEndOfText() { - let screenText = Ghostty.SurfaceView.ScreenText( - text: "hi", - viewportStartByte: 10, - viewportEndByte: 20 - ) - #expect(screenText.viewportRange.location == 2) - #expect(screenText.viewportRange.length == 0) - } - - @Test func reversedOffsetsCollapseToZeroLength() { - // A negative NSRange.length is meaningless to AX clients; the - // init normalizes to a zero-length range at start. - let screenText = Ghostty.SurfaceView.ScreenText( - text: "abcdef", - viewportStartByte: 4, - viewportEndByte: 2 - ) - #expect(screenText.viewportRange.length == 0) - } - - @Test func viewportInMiddleOfPureAscii() { - let text = "scrollback\nviewport line\nmore content" - let viewport = "viewport line\n" - let start = text.utf8.distance( - from: text.utf8.startIndex, - to: text.range(of: viewport)!.lowerBound.samePosition(in: text.utf8)! - ) - let screenText = Ghostty.SurfaceView.ScreenText( - text: text, - viewportStartByte: start, - viewportEndByte: start + viewport.utf8.count - ) - #expect(screenText.utf16Length == (text as NSString).length) - #expect(screenText.viewportRange.length == (viewport as NSString).length) - } - - // MARK: lineStarts - - @Test(arguments: [ - // Empty-text case is covered by emptyTextProducesEmpty. - ("hello", [0]), - ("a\nb\nc", [0, 2, 4]), - // CRLF is a single line terminator, not two. - ("a\r\nb", [0, 3]), - // Trailing newline creates an empty trailing line so the - // past-end cursor is reported on its own line. - ("a\nb\n", [0, 2, 4]), - ]) - func lineStarts(text: String, expected: [Int]) { - let screenText = Ghostty.SurfaceView.ScreenText( - text: text, viewportStartByte: 0, viewportEndByte: text.utf8.count - ) - #expect(screenText.lineStarts == expected) - } - - @Test(arguments: [ - (0, 0), // before first char - (1, 0), // on the '\n' - (2, 1), // start of second line - (3, 1), // inside second line - (4, 2), // start of third line - (5, 2), // past end clamps to last line - (100, 2) // way past end still clamps - ]) - func lineAtIndex(index: Int, expected: Int) { - let screenText = Ghostty.SurfaceView.ScreenText( - text: "a\nb\nc", viewportStartByte: 0, viewportEndByte: 5 - ) - #expect(screenText.line(at: index) == expected) - } - - @Test func lineAtNegativeIndexClampsToZero() { - let screenText = Ghostty.SurfaceView.ScreenText( - text: "a\nb", viewportStartByte: 0, viewportEndByte: 3 - ) - #expect(screenText.line(at: -1) == 0) - } - - @Test func lineForEmptyText() { - // Past-end indices clamp to line 0 when the text is empty; - // the in-range `at: 0` case is covered by lineAtIndex. - let screenText = Ghostty.SurfaceView.ScreenText( - text: "", viewportStartByte: 0, viewportEndByte: 0 - ) - #expect(screenText.line(at: 100) == 0) - } - - // MARK: accessibilityRange(of:in:) - @Test func accessibilityRangeFindsLoneOccurrence() { let range = Ghostty.SurfaceView.accessibilityRange( of: "selected",