|
| 1 | +import AppKit |
| 2 | +import Testing |
| 3 | + |
| 4 | +@testable import Spotlight |
| 5 | + |
| 6 | +@MainActor |
| 7 | +@Suite("Multiline editor vim logical line motions") |
| 8 | +struct MultilineEditorVimLogicalLineMotionTests { |
| 9 | + @Test("j steps one logical line at a time over checklist markers") |
| 10 | + func downOverChecklistMarkers() { |
| 11 | + let textView = makeVimMotionTextView(text: "plain\n☐ one\n☐ two\n☑ three\nafter") |
| 12 | + textView.setSelectedRange(NSRange(location: 0, length: 0)) |
| 13 | + |
| 14 | + textView.executeMotion(.down(1)) |
| 15 | + #expect(textView.selectedRange.location == ("plain\n" as NSString).length) |
| 16 | + |
| 17 | + textView.executeMotion(.down(1)) |
| 18 | + #expect(textView.selectedRange.location == ("plain\n☐ one\n" as NSString).length) |
| 19 | + } |
| 20 | + |
| 21 | + @Test("j and k step through fenced code block lines") |
| 22 | + func verticalMotionsOverFencedCodeBlock() { |
| 23 | + let textView = makeVimMotionTextView(text: "before\n```swift\nlet x = 1\nlet y = 2\n```\nafter") |
| 24 | + textView.setSelectedRange(NSRange(location: 0, length: 0)) |
| 25 | + |
| 26 | + textView.executeMotion(.down(3)) |
| 27 | + #expect(textView.selectedRange.location == ("before\n```swift\nlet x = 1\n" as NSString).length) |
| 28 | + |
| 29 | + textView.executeMotion(.up(1)) |
| 30 | + #expect(textView.selectedRange.location == ("before\n```swift\n" as NSString).length) |
| 31 | + } |
| 32 | + |
| 33 | + @Test("normal mode caret at code block line start uses that line fragment") |
| 34 | + func lineStartCaretRectInsideCodeBlock() { |
| 35 | + let textView = makeVimMotionTextView(text: "before\n```cpp\nint x;\nint y;\n```\nafter") |
| 36 | + let lineStart = ("before\n```cpp\n" as NSString).length |
| 37 | + textView.setSelectedRange(NSRange(location: lineStart, length: 0)) |
| 38 | + |
| 39 | + let rect = textView.normalizedInsertionPointRect( |
| 40 | + NSRect(x: 0, y: 0, width: 1, height: EditorMetrics.lineHeight) |
| 41 | + ) |
| 42 | + |
| 43 | + #expect(rect.origin.y == EditorMetrics.lineHeight * 2) |
| 44 | + } |
| 45 | + |
| 46 | + @Test("insert caret at closing fence end keeps the fence column") |
| 47 | + func closingFenceEndCaretRectKeepsXPosition() { |
| 48 | + let textView = makeVimMotionTextView(text: "before\n```cpp\nint x;\n```\nafter") |
| 49 | + let fenceEnd = ("before\n```cpp\nint x;\n```" as NSString).length |
| 50 | + textView.setSelectedRange(NSRange(location: fenceEnd, length: 0)) |
| 51 | + |
| 52 | + let rect = textView.normalizedInsertionPointRect( |
| 53 | + NSRect(x: 0, y: 0, width: 1, height: EditorMetrics.lineHeight) |
| 54 | + ) |
| 55 | + |
| 56 | + #expect(rect.origin.y == EditorMetrics.lineHeight * 3) |
| 57 | + #expect(rect.origin.x > 1) |
| 58 | + } |
| 59 | + |
| 60 | + @Test("trailing newline caret still uses the extra line fragment") |
| 61 | + func trailingNewlineCaretRectUsesExtraLineFragment() { |
| 62 | + let textView = makeVimMotionTextView(text: "before\n") |
| 63 | + textView.setSelectedRange(NSRange(location: ("before\n" as NSString).length, length: 0)) |
| 64 | + |
| 65 | + let rect = textView.normalizedInsertionPointRect( |
| 66 | + NSRect(x: 0, y: 0, width: 1, height: EditorMetrics.lineHeight) |
| 67 | + ) |
| 68 | + |
| 69 | + #expect(rect.origin.y == EditorMetrics.lineHeight) |
| 70 | + } |
| 71 | + |
| 72 | + private func makeVimMotionTextView(text: String) -> PlaceholderTextView { |
| 73 | + let textView = PlaceholderTextView(frame: NSRect(x: 0, y: 0, width: EditorMetrics.panelWidth, height: 240)) |
| 74 | + textView.font = .systemFont(ofSize: EditorMetrics.fontSize) |
| 75 | + textView.string = text |
| 76 | + textView.textContainer?.lineFragmentPadding = 0 |
| 77 | + textView.textContainer?.widthTracksTextView = true |
| 78 | + guard let storage = textView.textStorage, |
| 79 | + let container = textView.textContainer |
| 80 | + else { return textView } |
| 81 | + let fixed = FixedLineHeightLayoutManager() |
| 82 | + fixed.fixedLineHeight = EditorMetrics.lineHeight |
| 83 | + fixed.editorFont = textView.font ?? .systemFont(ofSize: EditorMetrics.fontSize) |
| 84 | + if let existing = storage.layoutManagers.first { |
| 85 | + storage.removeLayoutManager(existing) |
| 86 | + } |
| 87 | + storage.addLayoutManager(fixed) |
| 88 | + fixed.addTextContainer(container) |
| 89 | + CodeStyler.apply(to: textView, theme: ThemeCatalog.obsidian) |
| 90 | + return textView |
| 91 | + } |
| 92 | +} |
0 commit comments